From 4a13abc500d7c0f61acbc62edee975442994e4e5 Mon Sep 17 00:00:00 2001 From: lex Date: Mon, 10 Aug 2026 13:57:06 +0800 Subject: [PATCH 1/4] fix(dag): close orchestration acceptance gaps --- packages/core/src/plugin/command/dag-flow.txt | 6 +- .../src/plugin/command/workflow-blocks.md | 31 +- .../src/plugin/command/workflow-routing.md | 2 + packages/core/src/plugin/command/workflow.md | 50 +- packages/opencode/src/dag/blocks.ts | 245 ++++-- packages/opencode/src/dag/runtime/loop.ts | 381 +++++---- packages/opencode/src/session/tools.ts | 306 +++++--- packages/opencode/src/tool/task.ts | 13 +- packages/opencode/src/tool/workflow.ts | 133 +++- packages/opencode/test/dag/blocks.test.ts | 75 +- .../test/dag/dag-wake-integration.test.ts | 731 ++++++++++-------- .../test/dag/workflow-child-tools.test.ts | 19 +- .../opencode/test/dag/workflow-tool.test.ts | 724 ++++++++++------- packages/opencode/test/tool/task.test.ts | 49 +- 14 files changed, 1768 insertions(+), 997 deletions(-) diff --git a/packages/core/src/plugin/command/dag-flow.txt b/packages/core/src/plugin/command/dag-flow.txt index 301efd7460..dbc4e08d6b 100644 --- a/packages/core/src/plugin/command/dag-flow.txt +++ b/packages/core/src/plugin/command/dag-flow.txt @@ -35,5 +35,7 @@ failure, state that it did not start and report the real error; do not invent a replacement run. A final synthesis block must contain the requested result rather than a plan or -placeholder. The parent verifies that artifact, disposes of any non-ACCEPT -review verdict, and gives the user one final report. +placeholder. If its wake message says `truncated=true`, the parent reads every +page with `workflow(action="result")` before verification. The parent verifies +that complete artifact, disposes of any non-ACCEPT review verdict, and gives the +user one final report. diff --git a/packages/core/src/plugin/command/workflow-blocks.md b/packages/core/src/plugin/command/workflow-blocks.md index d2aaa7a6b4..5c0773b22f 100644 --- a/packages/core/src/plugin/command/workflow-blocks.md +++ b/packages/core/src/plugin/command/workflow-blocks.md @@ -57,15 +57,20 @@ or existing durable node IDs during **extend** and replan. - `debug`: expands to reproduce/evidence followed by root-cause diagnosis. - `coding`: bounded production implementation plus focused tests and checks. - `verify`: deterministic acceptance checks with explicit PASS/FAIL evidence. -- `review`: expands to independent standards and intent reviews, then one - structured arbiter returning `ACCEPT | REVISE | REJECT | BLOCKED`. +- `review`: design/content inputs expand to independent standards and intent + reviews plus a general arbiter. An implementation input must follow a + `coding → verify(PASS) → review` route; the compiler binds the implementation + fingerprint through both reviews into an `ACCEPT | REJECT` decision. - `synthesize`: resolves dependency outputs into the parent-facing result. -Every compiled block is required by default. `review` and `synthesize` report -to the parent by default; other blocks stay quiet. A block immediately after a -review gate is conditioned on `ACCEPT`. Because the condition language handles -one verdict reference, fan multiple review lanes into one review block before -continuing. +Judgment and acceptance gates (`plan`, debug diagnosis, `verify`, review +decision, and `synthesize`) are required by default. Volume lanes (`explore`, +`prototype`, `coding`, debug evidence, and independent review lanes) are +optional by default; an explicit `required` value on a block overrides its +default. `review` and `synthesize` report to the parent by default; other blocks +stay quiet. A block immediately after a review gate is conditioned on its +accepted verdict. Because the condition language handles one verdict reference, +fan multiple review lanes into one review block before continuing. ## Composition routes @@ -73,8 +78,8 @@ Choose only blocks justified by current evidence: - Product or architecture decision: parallel `explore` lanes → `plan` options → `review` or `synthesize`. -- Project feature: optional `explore` → `plan` → parallel `coding` packages → - `verify` → `review`. +- Project feature: optional parallel `explore` or proposal lanes → `plan` → + ordered `coding`/assembly → `verify` → `review`. - Hard bug: `debug` → `coding` → `verify` → `review`. - Runnable design uncertainty: `prototype` → `plan`; keep the prototype disposable unless the confirmed scope explicitly promotes it. @@ -82,9 +87,11 @@ Choose only blocks justified by current evidence: separate verification block first when test evidence is required. Do not add a phase merely because it exists. Skip exploration when repository -facts are already known, skip a prototype when ordinary inspection resolves -the question, and keep independent work parallel. Use `synthesize` only when -multiple outputs need reconciliation. +facts are already known and skip a prototype when ordinary inspection resolves +the question. All block workers share one workspace: the compiler serializes +otherwise-unordered `coding` and `prototype` writers, while read-only discovery +and proposal lanes remain parallel. Use `synthesize` only when multiple outputs +need reconciliation. ## Parent decision checkpoint diff --git a/packages/core/src/plugin/command/workflow-routing.md b/packages/core/src/plugin/command/workflow-routing.md index 86032b3fc9..dc027c2567 100644 --- a/packages/core/src/plugin/command/workflow-routing.md +++ b/packages/core/src/plugin/command/workflow-routing.md @@ -44,6 +44,8 @@ Load details only when needed: - **extend** adds nodes or blocks to the same objective. - **status** reads durable state when the user asks or before a control decision; it is not a waiting mechanism. +- **result** reads one node's complete durable output in bounded pages when a + wake preview reports `truncated=true`. - **control** pauses, resumes, cancels, replans, steps, or completes a workflow. - **list** shows saved workflow specs and their resolution scope. - **read** returns one saved spec so the parent can retarget it before start. diff --git a/packages/core/src/plugin/command/workflow.md b/packages/core/src/plugin/command/workflow.md index bfa260a205..d23c2a88fc 100644 --- a/packages/core/src/plugin/command/workflow.md +++ b/packages/core/src/plugin/command/workflow.md @@ -408,12 +408,12 @@ appears as `failed` with error_reason `cancelled via replan` and NO `error_class` — deliberate action, no triage needed. Triage on the class before acting: -| error_class | What it means | Correct response | -|---|---|---| -| `timeout` | The node exceeded `timeout_ms`; the runtime cancelled its child session at the deadline. Environmental — the task is NOT wrong. | Replace and rerun ONLY that node with a larger `worker_config.timeout_ms`. Check its `child_session_id` for partial artifacts before rerunning. | -| `exec_failed` | Runtime/session-level failure. Gate on `error_reason`: (a) unknown/wrong model, auth, rate-limit, connection, template-resolution or condition-expression errors → config/prompt errors; (b) recovery reasons ("no child session on recovery", "child session failed (recovered)") → crash ownership loss; (c) workflow-collateral reasons (`required node(s) failed: ...`, `unresolved review outcome(s): ...`, `orchestrator_unresponsive`) → the node itself was fine; it was failed because the workflow failed. | (a) Fix the config first (`dag.jsonc` tier, provider credentials, model id, template/input mapping), then replace and rerun ONLY that node. (b) Inspect the child session's artifacts, then replace and rerun. (c) Do not rerun these collateral nodes. The wake surfaces no workflow-level reason — triage from the Failed-nodes block: `required node(s) failed: ` names the culprit nodes directly (repair them); `orchestrator_unresponsive` carries NO attribution (see the recipe below). | -| `verdict_fail` | Two shapes. Ran-but-broke-contract: missing `submit_result`, schema rejection, review fingerprint mismatch. Never-ran: pre-spawn contract failures (unresolved template placeholders, review input contract). | Ran-but-broke-contract → rerun the node with the contract stated explicitly; keep the topology. Never-ran → fix the template, input_mapping, or dependency wiring first, then rerun; prompt emphasis alone does not fix broken interpolation. | -| (cascade — see below) | Dependents of a failed node. No dedicated class. | Repair the ROOT node first, then restore the dependent subtree. | +| error_class | What it means | Correct response | +| --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `timeout` | The node exceeded `timeout_ms`; the runtime cancelled its child session at the deadline. Environmental — the task is NOT wrong. | Replace and rerun ONLY that node with a larger `worker_config.timeout_ms`. Check its `child_session_id` for partial artifacts before rerunning. | +| `exec_failed` | Runtime/session-level failure. Gate on `error_reason`: (a) unknown/wrong model, auth, rate-limit, connection, template-resolution or condition-expression errors → config/prompt errors; (b) recovery reasons ("no child session on recovery", "child session failed (recovered)") → crash ownership loss; (c) workflow-collateral reasons (`required node(s) failed: ...`, `unresolved review outcome(s): ...`, `orchestrator_unresponsive`) → the node itself was fine; it was failed because the workflow failed. | (a) Fix the config first (`dag.jsonc` tier, provider credentials, model id, template/input mapping), then replace and rerun ONLY that node. (b) Inspect the child session's artifacts, then replace and rerun. (c) Do not rerun these collateral nodes. The wake surfaces no workflow-level reason — triage from the Failed-nodes block: `required node(s) failed: ` names the culprit nodes directly (repair them); `orchestrator_unresponsive` carries NO attribution (see the recipe below). | +| `verdict_fail` | Two shapes. Ran-but-broke-contract: missing `submit_result`, schema rejection, review fingerprint mismatch. Never-ran: pre-spawn contract failures (unresolved template placeholders, review input contract). | Ran-but-broke-contract → rerun the node with the contract stated explicitly; keep the topology. Never-ran → fix the template, input_mapping, or dependency wiring first, then rerun; prompt emphasis alone does not fix broken interpolation. | +| (cascade — see below) | Dependents of a failed node. No dedicated class. | Repair the ROOT node first, then restore the dependent subtree. | Cascade detection has two shapes: @@ -564,7 +564,15 @@ then call `{ action: "extend", workflow_id: "dag_...", spec: { nodes: [...] } }` **status** — Read the durable state of one workflow and all of its nodes. Pass `workflow_id`. Use it when the user explicitly asks for current state or once before a decision that requires fresh state, such as replan/control. Do not poll a running workflow merely to wait: node reports and terminal outcomes wake the parent session automatically. +**result** — Read one node's complete durable output in bounded pages. Pass +`workflow_id` and `node_id`; when the response is truncated, pass its +`next_cursor` unchanged until no cursor remains. Wake messages contain only a +bounded preview plus the exact workflow/node reference, so use `result` before +verifying or synthesizing any output marked `truncated=true`. Never infer the +omitted content from its preview. + **control** — Control a running workflow: + - `pause` — let running nodes finish, don't spawn new ones (pause does NOT stop nodes that are already running). On a cancel/replan intent, always pause FIRST: it needs no fragment and freezes scheduling while you compose the replan, so the graph cannot terminalize under you. - `resume` — resume scheduling - `cancel` — cancel the entire workflow @@ -574,21 +582,21 @@ then call `{ action: "extend", workflow_id: "dag_...", spec: { nodes: [...] } }` ### Node Fields -| Field | Required | Description | -|-------|----------|-------------| -| `id` | yes | Unique node identifier, used in `depends_on` | -| `name` | yes | Human-readable name | -| `worker_type` | yes | Agent type (`explore`, `build`, `general`, `plan`, or custom) | -| `depends_on` | yes | Array of node IDs this node waits for (`[]` for root) | -| `required` | no | If true and this node fails, the workflow terminalizes as failed. Default: false | -| `prompt_template` | yes | `{ id: "..." }` or `{ inline: "...", input: {...} }` | -| `condition` | no | Expression evaluated before spawn; node is skipped if false | -| `input_mapping` | no | Map upstream node outputs into template variables | -| `report_to_parent` | no | If true, the parent agent is woken when this node completes or fails. The workflow's terminal status always wakes the parent regardless of this flag | -| `worker_config` | no | `{ timeout_ms }` — bounds node execution (defaults to 10 minutes if omitted) | -| `output_schema` | no | JSON Schema; when declared, the child agent must call `submit_result` to submit structured output — failure to submit results in node failure | -| `restart` | no | (replan only) Re-spawn this running node with new prompt | -| `cancel` | no | (replan only) Cancel this node | +| Field | Required | Description | +| ------------------ | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | +| `id` | yes | Unique node identifier, used in `depends_on` | +| `name` | yes | Human-readable name | +| `worker_type` | yes | Agent type (`explore`, `build`, `general`, `plan`, or custom) | +| `depends_on` | yes | Array of node IDs this node waits for (`[]` for root) | +| `required` | no | If true and this node fails, the workflow terminalizes as failed. Default: false | +| `prompt_template` | yes | `{ id: "..." }` or `{ inline: "...", input: {...} }` | +| `condition` | no | Expression evaluated before spawn; node is skipped if false | +| `input_mapping` | no | Map upstream node outputs into template variables | +| `report_to_parent` | no | If true, the parent agent is woken when this node completes or fails. The workflow's terminal status always wakes the parent regardless of this flag | +| `worker_config` | no | `{ timeout_ms }` — bounds node execution (defaults to 10 minutes if omitted) | +| `output_schema` | no | JSON Schema; when declared, the child agent must call `submit_result` to submit structured output — failure to submit results in node failure | +| `restart` | no | (replan only) Re-spawn this running node with new prompt | +| `cancel` | no | (replan only) Cancel this node | ### What NOT to expect diff --git a/packages/opencode/src/dag/blocks.ts b/packages/opencode/src/dag/blocks.ts index ef93a76c52..10424ab28a 100644 --- a/packages/opencode/src/dag/blocks.ts +++ b/packages/opencode/src/dag/blocks.ts @@ -14,7 +14,7 @@ export const WORKFLOW_BLOCK_KINDS = [ export type WorkflowBlockKind = (typeof WORKFLOW_BLOCK_KINDS)[number] -export const WorkflowBlock = Schema.Struct({ +export class WorkflowBlock extends Schema.Class("WorkflowBlock")({ id: Schema.String.annotate({ description: "Unique block identifier; dependencies target block IDs" }), kind: Schema.Literals(WORKFLOW_BLOCK_KINDS).annotate({ description: "Composable workflow block; debug and review expand into evidence-gathering subgraphs", @@ -38,8 +38,7 @@ export const WorkflowBlock = Schema.Struct({ report_to_parent: Schema.optional(Schema.Boolean).annotate({ description: "Override wake behavior. Review decisions and synthesis report by default", }), -}) -export type WorkflowBlock = typeof WorkflowBlock.Type +}) {} export interface WorkflowBlockGraph { objective: string @@ -50,7 +49,7 @@ export interface WorkflowBlockCompileOptions { known_dependencies?: string[] } -const VERDICT_SCHEMA = { +const GENERAL_VERDICT_SCHEMA = { type: "object", required: ["verdict", "summary", "findings", "required_actions"], properties: { @@ -64,6 +63,40 @@ const VERDICT_SCHEMA = { }, } as const +const IMPLEMENTATION_SCHEMA = { + type: "object", + required: ["summary", "changed_files", "fingerprint"], + properties: { + summary: { type: "string" }, + changed_files: { type: "array", items: { type: "string" } }, + fingerprint: { type: "string" }, + }, +} as const + +const VERIFICATION_SCHEMA = { + type: "object", + required: ["verdict", "summary", "evidence"], + properties: { + verdict: { type: "string", enum: ["PASS", "FAIL"] }, + summary: { type: "string" }, + evidence: { type: "array" }, + }, +} as const + +const DIFF_REVIEW_SCHEMA = { + type: "object", + required: ["verdict", "implementation_fingerprint", "summary", "findings", "required_actions"], + properties: { + verdict: { type: "string", enum: ["ACCEPT", "REJECT"] }, + implementation_fingerprint: { type: "string" }, + summary: { type: "string" }, + findings: { type: "array" }, + required_actions: { type: "array" }, + }, +} as const + +const WRITER_KINDS = new Set(["coding", "prototype"]) + const BLOCK_CONTRACTS: Record = { explore: "Inspect the target read-only. Map relevant modules, constraints, existing conventions, and evidence with file references. Do not implement.", @@ -73,9 +106,9 @@ const BLOCK_CONTRACTS: Record = { debug: "Diagnose the smallest falsifiable root-cause hypothesis from reproduced evidence. Distinguish cause from symptom and identify the narrowest safe repair plus a regression check.", coding: - "Implement the bounded production change. Follow repository instructions, preserve unrelated work, add or update focused tests, run relevant checks, and report changed files plus evidence.", + "Implement the bounded production change. Follow repository instructions, preserve unrelated work, add or update focused tests, and run relevant checks. Submit the aggregate changed-file list and a stable fingerprint of the actual implementation state so downstream verification and review can detect stale evidence.", verify: - "Verify the supplied work against acceptance criteria using deterministic checks where available. Report commands, results, uncovered claims, and a clear PASS or FAIL conclusion. Do not hide failures.", + "Verify the supplied work against acceptance criteria using deterministic checks where available. Submit commands and evidence with an explicit PASS or FAIL verdict. Do not hide failures.", review: "Review independently against repository standards and the confirmed intent. Cite concrete evidence, separate blockers from suggestions, and identify claims that still need verification.", synthesize: @@ -86,38 +119,10 @@ export function compileWorkflowBlocks( graph: WorkflowBlockGraph, options: WorkflowBlockCompileOptions = {}, ): NodeConfig[] { - if (graph.objective.trim() === "") throw new Error("Block workflow requires a non-empty objective") - if (graph.blocks.length === 0) throw new Error("Block workflow requires at least one block") - - const blockIDs = graph.blocks.map((block) => block.id) - const duplicateBlockIDs = uniqueDuplicates(blockIDs) - if (duplicateBlockIDs.length > 0) { - throw new Error(`Block workflow has duplicate block ids: ${duplicateBlockIDs.join(", ")}`) - } - - const known = new Set([...blockIDs, ...(options.known_dependencies ?? [])]) - for (const block of graph.blocks) { - if (block.id.trim() === "") throw new Error("Block workflow contains an empty block id") - if (!/^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(block.id)) { - throw new Error(`Block "${block.id}" must use only letters, numbers, underscores, and hyphens`) - } - for (const dependency of block.depends_on ?? []) { - if (!known.has(dependency)) { - throw new Error(`Block "${block.id}" depends on unknown block "${dependency}"`) - } - } - const reviewDependencies = (block.depends_on ?? []).filter( - (dependency) => graph.blocks.find((candidate) => candidate.id === dependency)?.kind === "review", - ) - if (reviewDependencies.length > 1) { - throw new Error( - `Block "${block.id}" depends on multiple review gates (${reviewDependencies.join(", ")}); fan them into one review block first`, - ) - } - } - assertAcyclic(graph.blocks) - - const nodes = graph.blocks.flatMap((block) => compileBlock(graph.objective, block, graph.blocks)) + requireValidBlockGraph(graph, options) + const blocks = serializeWorkspaceWriters(graph.blocks) + requireValidReviewRoutes(blocks) + const nodes = blocks.flatMap((block) => compileBlock(graph.objective, block, blocks)) const duplicateNodeIDs = uniqueDuplicates(nodes.map((node) => node.id)) if (duplicateNodeIDs.length > 0) { throw new Error( @@ -148,7 +153,7 @@ function compileBlock(objective: string, block: WorkflowBlock, blocks: WorkflowB skills: block.skills, contract: "Reproduce or characterize the failure read-only where possible. Capture exact symptoms, commands, logs, boundaries, and the smallest falsifiable observations. Do not patch the code.", - required: block.required ?? false, + required: false, reportToParent: false, condition, }), @@ -170,6 +175,15 @@ function compileBlock(objective: string, block: WorkflowBlock, blocks: WorkflowB if (block.kind === "review") { const standardsID = `${block.id}--standards` const intentID = `${block.id}--intent` + const route = implementationReviewRoute(block, blocks) + const reviewCondition = route ? `${route.verification.id}.output.verdict == "PASS"` : condition + const reviewEvidence = route + ? { + implementation_changed_files: `${route.implementation.id}.output.changed_files`, + implementation_fingerprint: `${route.implementation.id}.output.fingerprint`, + verification: `${route.verification.id}.output`, + } + : undefined return [ node({ id: standardsID, @@ -180,9 +194,10 @@ function compileBlock(objective: string, block: WorkflowBlock, blocks: WorkflowB instruction: block.instruction, skills: block.skills, contract: `${BLOCK_CONTRACTS.review} Focus on documented repository standards, architecture constraints, correctness, and verification evidence.`, - required: block.required ?? false, + required: false, reportToParent: false, - condition, + condition: reviewCondition, + inputMapping: reviewEvidence, }), node({ id: intentID, @@ -193,26 +208,44 @@ function compileBlock(objective: string, block: WorkflowBlock, blocks: WorkflowB instruction: block.instruction, skills: block.skills, contract: `${BLOCK_CONTRACTS.review} Focus on the confirmed goal, scope, acceptance criteria, and user-visible behavior.`, - required: block.required ?? false, + required: false, reportToParent: false, - condition, + condition: reviewCondition, + inputMapping: reviewEvidence, }), node({ id: block.id, name: `${block.id}: review decision`, workerType: block.worker_type ?? "general", - dependencies: [standardsID, intentID], + dependencies: [standardsID, intentID, ...(route ? [route.verification.id] : [])], objective, instruction: block.instruction, skills: block.skills, contract: [ "Arbitrate the two independent reviews finding by finding.", - "Reject unsupported claims, deduplicate overlaps, and submit one structured result with verdict ACCEPT, REVISE, REJECT, or BLOCKED.", + route + ? "Reject unsupported claims, deduplicate overlaps, and submit ACCEPT or REJECT while echoing the supplied implementation fingerprint exactly." + : "Reject unsupported claims, deduplicate overlaps, and submit one structured result with verdict ACCEPT, REVISE, REJECT, or BLOCKED.", "Use ACCEPT only when no material required action remains.", ].join(" "), required: block.required ?? true, reportToParent: block.report_to_parent ?? true, - outputSchema: VERDICT_SCHEMA, + condition: reviewCondition, + inputMapping: route + ? { + ...reviewEvidence, + standards_review: `${standardsID}.output`, + intent_review: `${intentID}.output`, + } + : undefined, + review: route + ? { + phase: "diff", + implementation_node_id: route.implementation.id, + verification_node_id: route.verification.id, + } + : undefined, + outputSchema: route ? DIFF_REVIEW_SCHEMA : GENERAL_VERDICT_SCHEMA, }), ] } @@ -230,6 +263,8 @@ function compileBlock(objective: string, block: WorkflowBlock, blocks: WorkflowB required, reportToParent: block.report_to_parent ?? block.kind === "synthesize", condition, + outputSchema: + block.kind === "coding" ? IMPLEMENTATION_SCHEMA : block.kind === "verify" ? VERIFICATION_SCHEMA : undefined, }), ] } @@ -246,6 +281,8 @@ function node(input: { required: boolean reportToParent: boolean condition?: string + inputMapping?: Record + review?: NodeConfig["review"] outputSchema?: Record }): NodeConfig { const skillInstruction = input.skills?.length @@ -275,6 +312,8 @@ function node(input: { }, }, ...(input.condition ? { condition: input.condition } : {}), + ...(input.inputMapping ? { input_mapping: input.inputMapping } : {}), + ...(input.review ? { review: input.review } : {}), ...(input.outputSchema ? { output_schema: input.outputSchema } : {}), } } @@ -286,12 +325,114 @@ function workerType(kind: WorkflowBlockKind) { return "general" } +function requireValidBlockGraph(graph: WorkflowBlockGraph, options: WorkflowBlockCompileOptions) { + if (graph.objective.trim() === "") throw new Error("Block workflow requires a non-empty objective") + if (graph.blocks.length === 0) throw new Error("Block workflow requires at least one block") + + const blockIDs = graph.blocks.map((block) => block.id) + const duplicateBlockIDs = uniqueDuplicates(blockIDs) + if (duplicateBlockIDs.length > 0) { + throw new Error(`Block workflow has duplicate block ids: ${duplicateBlockIDs.join(", ")}`) + } + + const known = new Set([...blockIDs, ...(options.known_dependencies ?? [])]) + graph.blocks.forEach((block) => { + if (block.id.trim() === "") throw new Error("Block workflow contains an empty block id") + if (!/^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(block.id)) { + throw new Error(`Block "${block.id}" must use only letters, numbers, underscores, and hyphens`) + } + ;(block.depends_on ?? []).forEach((dependency) => { + if (!known.has(dependency)) { + throw new Error(`Block "${block.id}" depends on unknown block "${dependency}"`) + } + }) + const reviewDependencies = (block.depends_on ?? []).filter( + (dependency) => graph.blocks.find((candidate) => candidate.id === dependency)?.kind === "review", + ) + if (reviewDependencies.length > 1) { + throw new Error( + `Block "${block.id}" depends on multiple review gates (${reviewDependencies.join(", ")}); fan them into one review block first`, + ) + } + }) + topologicalBlocks(graph.blocks) +} + +function serializeWorkspaceWriters(blocks: WorkflowBlock[]) { + const writers = topologicalBlocks(blocks).filter((block) => WRITER_KINDS.has(block.kind)) + const previousWriter = new Map( + writers.slice(1).map((block, index) => [block.id, writers[index]?.id ?? block.id] as const), + ) + const serialized = blocks.map((block) => { + const previous = previousWriter.get(block.id) + if (!previous || dependsTransitively(blocks, block.id, previous)) return block + return new WorkflowBlock({ + ...block, + depends_on: [...(block.depends_on ?? []), previous], + }) + }) + topologicalBlocks(serialized) + return serialized +} + +function requireValidReviewRoutes(blocks: WorkflowBlock[]) { + blocks.filter((block) => block.kind === "review").forEach((block) => implementationReviewRoute(block, blocks)) +} + +function implementationReviewRoute(block: WorkflowBlock, blocks: WorkflowBlock[]) { + const implementations = blocks.filter( + (candidate) => WRITER_KINDS.has(candidate.kind) && dependsTransitively(blocks, block.id, candidate.id), + ) + if (implementations.length === 0) return undefined + const verifications = blocks.filter( + (candidate) => candidate.kind === "verify" && dependsTransitively(blocks, block.id, candidate.id), + ) + if (verifications.length !== 1) { + throw new Error( + `Implementation review "${block.id}" requires exactly one verification ancestor; found ${verifications.length}`, + ) + } + const verification = verifications[0] + if (!verification) throw new Error(`Implementation review "${block.id}" has no verification ancestor`) + const verifiedImplementations = implementations.filter((candidate) => + dependsTransitively(blocks, verification.id, candidate.id), + ) + if (verifiedImplementations.length !== implementations.length) { + throw new Error( + `Implementation review "${block.id}" requires its verification ancestor to depend on every implementation writer`, + ) + } + const implementation = verifiedImplementations.find((candidate) => + verifiedImplementations.every( + (other) => other.id === candidate.id || dependsTransitively(blocks, candidate.id, other.id), + ), + ) + if (!implementation) { + throw new Error(`Implementation review "${block.id}" has no canonical serialized implementation writer`) + } + return { implementation, verification } +} + +function dependsTransitively( + blocks: WorkflowBlock[], + blockID: string, + dependencyID: string, + visited = new Set(), +): boolean { + if (visited.has(blockID)) return false + const dependencies = blocks.find((block) => block.id === blockID)?.depends_on ?? [] + if (dependencies.includes(dependencyID)) return true + const nextVisited = new Set([...visited, blockID]) + return dependencies.some((dependency) => dependsTransitively(blocks, dependency, dependencyID, nextVisited)) +} + function uniqueDuplicates(values: string[]) { return [...new Set(values.filter((value, index) => values.indexOf(value) !== index))] } -function assertAcyclic(blocks: WorkflowBlock[]) { +function topologicalBlocks(blocks: WorkflowBlock[]) { const blockIDs = new Set(blocks.map((block) => block.id)) + const ordered: WorkflowBlock[] = [] const remaining = new Map( blocks.map((block) => [ block.id, @@ -299,15 +440,15 @@ function assertAcyclic(blocks: WorkflowBlock[]) { ]), ) while (remaining.size > 0) { - const ready = [...remaining].filter(([, dependencies]) => dependencies.size === 0).map(([id]) => id) + const ready = blocks.filter((block) => remaining.get(block.id)?.size === 0) if (ready.length === 0) { throw new Error(`Block workflow contains a dependency cycle involving: ${[...remaining.keys()].join(", ")}`) } - for (const id of ready) remaining.delete(id) - for (const dependencies of remaining.values()) { - for (const id of ready) dependencies.delete(id) - } + ready.forEach((block) => remaining.delete(block.id)) + remaining.forEach((dependencies) => ready.forEach((block) => dependencies.delete(block.id))) + ordered.push(...ready) } + return ordered } export * as DagBlocks from "./blocks" diff --git a/packages/opencode/src/dag/runtime/loop.ts b/packages/opencode/src/dag/runtime/loop.ts index cd1727eddf..664beedb02 100644 --- a/packages/opencode/src/dag/runtime/loop.ts +++ b/packages/opencode/src/dag/runtime/loop.ts @@ -124,7 +124,9 @@ export const layer = Layer.effect( const promptParts: { type: "text"; text: string }[] = [] let resolvedMapping: Record = {} - const inputMapping = nodeConfig?.input_mapping ?? Object.fromEntries(node.dependsOn.map((dependency) => [dependency, dependency])) + const inputMapping = + nodeConfig?.input_mapping ?? + Object.fromEntries(node.dependsOn.map((dependency) => [dependency, dependency])) if (Object.keys(inputMapping).length > 0) { resolvedMapping = resolveInputMapping(inputMapping, (depId) => { const depNode = nodesSnapshot.find((n) => n.id === depId) @@ -151,17 +153,19 @@ export const layer = Layer.effect( if (nodeConfig) { const reviewInput = validateReviewExecutionInput(nodeConfig, resolvedMapping) if (!reviewInput.valid) { - yield* dag.nodeFailed( - dagID, - nodeID, - `Review input contract failed: ${reviewInput.errors.join("; ")}`, - "verdict_fail", - ).pipe(Effect.ignore) + yield* dag + .nodeFailed( + dagID, + nodeID, + `Review input contract failed: ${reviewInput.errors.join("; ")}`, + "verdict_fail", + ) + .pipe(Effect.ignore) continue } } - const resolved = yield* (nodeConfig?.prompt_template + const resolved = yield* nodeConfig?.prompt_template ? renderTemplate(nodeConfig.prompt_template, ctx.directory, resolvedMapping).pipe( Effect.tap((result) => result.text.trim() === "" @@ -171,7 +175,9 @@ export const layer = Layer.effect( Effect.map((result) => ({ ok: true as const, ...result })), Effect.catch((err: unknown) => Effect.gen(function* () { - yield* dag.nodeFailed(dagID, nodeID, `Template resolution failed: ${String(err)}`, "exec_failed").pipe(Effect.ignore) + yield* dag + .nodeFailed(dagID, nodeID, `Template resolution failed: ${String(err)}`, "exec_failed") + .pipe(Effect.ignore) return { ok: false as const, text: "", unresolvedPlaceholders: [] } }), ), @@ -180,16 +186,18 @@ export const layer = Layer.effect( ok: true as const, text: node.name, unresolvedPlaceholders: [], - })) + }) if (!resolved.ok) continue if (resolved.unresolvedPlaceholders.length > 0) { - yield* dag.nodeFailed( - dagID, - nodeID, - `Unresolved template placeholders: ${resolved.unresolvedPlaceholders.join(", ")}`, - "verdict_fail", - ).pipe(Effect.ignore) + yield* dag + .nodeFailed( + dagID, + nodeID, + `Unresolved template placeholders: ${resolved.unresolvedPlaceholders.join(", ")}`, + "verdict_fail", + ) + .pipe(Effect.ignore) continue } @@ -243,7 +251,8 @@ export const layer = Layer.effect( : undefined, fallbackModel: DagConfig.tierModel(dagConfig, { required: node.required, workerType: node.workerType }), variant: dagConfig.thinking_depth, - maxTimeoutExtensions: entry.config?.max_timeout_extensions ?? Dag.DEFAULT_WORKFLOW_CONFIG.maxTimeoutExtensions, + maxTimeoutExtensions: + entry.config?.max_timeout_extensions ?? Dag.DEFAULT_WORKFLOW_CONFIG.maxTimeoutExtensions, }).pipe( Effect.tap((result) => Effect.sync(() => { @@ -255,9 +264,7 @@ export const layer = Layer.effect( Effect.provideService(Agent.Service, agentSvc), Effect.provideService(Session.Service, sessionSvc), Effect.provideService(SessionPrompt.Service, promptSvc), - Effect.catchCause((cause) => - dag.nodeFailed(dagID, nodeID, Cause.pretty(cause), "exec_failed"), - ), + Effect.catchCause((cause) => dag.nodeFailed(dagID, nodeID, Cause.pretty(cause), "exec_failed")), Effect.ignore, ) } @@ -286,9 +293,7 @@ export const layer = Layer.effect( yield* dag.fail(dagID, `required node(s) failed: ${entry.runtime.getRequiredFailures().join(", ")}`) return } - const unresolvedReviews = entry.config - ? unresolvedReviewOutcomes(entry.config, nodes) - : [] + const unresolvedReviews = entry.config ? unresolvedReviewOutcomes(entry.config, nodes) : [] if (unresolvedReviews.length > 0) { yield* dag.fail(dagID, `unresolved review outcome(s): ${unresolvedReviews.join(", ")}`) return @@ -311,8 +316,10 @@ export const layer = Layer.effect( // absorbs the error channel) and would kill the forked runForEach fiber — // leaving that event type permanently unhandled for the rest of the // process. catchCause absorbs failures AND defects at the boundary. - const guarded = (event: string) => (self: Effect.Effect) => - self.pipe(Effect.catchCause((cause) => Effect.logWarning("DagLoop handler failed", { event, cause }))) + const guarded = + (event: string) => + (self: Effect.Effect) => + self.pipe(Effect.catchCause((cause) => Effect.logWarning("DagLoop handler failed", { event, cause }))) const recoverWorkflow = Effect.fn("DagLoop.recoverWorkflow")(function* (wf: DagStore.WorkflowRow) { // Cross-instance guard: DagLoop is per-directory InstanceState but the @@ -340,9 +347,7 @@ export const layer = Layer.effect( checkSessionStatus, (sid) => promptSvc.cancel(sid as never), config, - ).pipe( - Effect.provideService(Dag.Service, dag), - ) + ).pipe(Effect.provideService(Dag.Service, dag)) // P2-2 recovery-pause: reconciliation invented failures (ownership // lost / no child session / deadline enforced offline) without any // durable proof of the child's outcome. Letting spawnReady cascade @@ -389,7 +394,15 @@ export const layer = Layer.effect( const isStepping = wf.status === "stepping" if (isPaused) runtime.setPaused(true) if (isStepping) runtime.setStepMode(true) - const entry: WorkflowEntry = { runtime, semaphore, evalLock: Semaphore.makeUnsafe(1), parentSessionID: wf.sessionId, config, fibers: new Map(), watchers: new Map() } + const entry: WorkflowEntry = { + runtime, + semaphore, + evalLock: Semaphore.makeUnsafe(1), + parentSessionID: wf.sessionId, + config, + fibers: new Map(), + watchers: new Map(), + } runtimes.set(dagID, entry) // Reconciliation settles every persisted running attempt before the // runtime is rebuilt. Recovery never adopts or restarts provider work; @@ -481,7 +494,15 @@ export const layer = Layer.effect( const maxConcurrency = Math.max(1, config?.max_concurrency ?? Dag.DEFAULT_WORKFLOW_CONFIG.maxConcurrency) const runtime = new WorkflowRuntime(toSchedulingNodes(nodes), maxConcurrency) const semaphore = Semaphore.makeUnsafe(maxConcurrency) - const entry: WorkflowEntry = { runtime, semaphore, evalLock: Semaphore.makeUnsafe(1), parentSessionID: wf.sessionId, config, fibers: new Map(), watchers: new Map() } + const entry: WorkflowEntry = { + runtime, + semaphore, + evalLock: Semaphore.makeUnsafe(1), + parentSessionID: wf.sessionId, + config, + fibers: new Map(), + watchers: new Map(), + } runtimes.set(dagID, entry) yield* entry.evalLock.withPermits(1)( Effect.gen(function* () { @@ -498,9 +519,10 @@ export const layer = Layer.effect( // A completed node is an output-producing success; a skipped node is a // terminal no-output state that must stay distinguishable so pure-skip // descendants cascade instead of running (D13). - const settle = def === DagEvent.NodeSkipped - ? (entry: WorkflowEntry, nodeID: string) => entry.runtime.markSkipped(nodeID) - : (entry: WorkflowEntry, nodeID: string) => entry.runtime.markSatisfied(nodeID) + const settle = + def === DagEvent.NodeSkipped + ? (entry: WorkflowEntry, nodeID: string) => entry.runtime.markSkipped(nodeID) + : (entry: WorkflowEntry, nodeID: string) => entry.runtime.markSatisfied(nodeID) yield* events.subscribe(def).pipe( Stream.filter((e) => runtimes.has(e.data.dagID as string)), Stream.runForEach((evt) => @@ -547,7 +569,12 @@ export const layer = Layer.effect( entry.watchers.delete(nodeID) } if (!confirmed) { - yield* Effect.logDebug("DagLoop dropped stale node terminal event", { dagID, nodeID, expected, dbStatus: node?.status ?? "missing" }) + yield* Effect.logDebug("DagLoop dropped stale node terminal event", { + dagID, + nodeID, + expected, + dbStatus: node?.status ?? "missing", + }) } const workflow = yield* store.getWorkflow(dagID) entry.runtime.setPaused(workflow?.status === "paused") @@ -606,49 +633,53 @@ export const layer = Layer.effect( yield* events.subscribe(DagEvent.NodeFailed).pipe( Stream.filter((e) => runtimes.has(e.data.dagID as string)), - Stream.runForEach((evt) => - Effect.gen(function* () { - const dagID = evt.data.dagID as string - const entry = runtimes.get(dagID) - if (!entry) return - yield* entry.evalLock.withPermits(1)( - Effect.gen(function* () { - const nid = evt.data.nodeID as string - // Generation arbitration via DB status: each projector runs - // INSIDE the durable publish transaction (core/dag/projector.ts), - // so by the time this handler consumes the event the row - // already reflects it. If the row is no longer "failed", a - // later NodeRestarted/replan reset the node — this event - // belongs to a previous generation and must not touch the - // new one (including the fiber map, which may already hold - // the new attempt's fiber). No generation field needed. - const node = yield* store.getNode(dagID, nid) - // #3: only markUnsatisfied if the runtime still tracks this - // node as non-terminal. A stale NodeFailed event (e.g. from - // a replan-ceiling check after the node already completed) - // would incorrectly flip a satisfied node to unsatisfied. - if (node?.status === "failed" && entry.runtime.isActive(nid)) { - const fiber = entry.fibers.get(nid) - const watcher = entry.watchers.get(nid) - entry.fibers.delete(nid) - entry.watchers.delete(nid) - yield* abortChild(nid, node.childSessionId ?? null).pipe(Effect.ignore) - if (fiber) yield* Fiber.interrupt(fiber).pipe(Effect.ignore) - if (watcher) yield* Fiber.interrupt(watcher).pipe(Effect.ignore) - entry.runtime.markUnsatisfied(nid) - if (!entry.runtime.isStepMode()) yield* spawnReady(dagID) - } - if (node?.status !== "failed") { - yield* Effect.logDebug("DagLoop dropped stale NodeFailed", { dagID, nodeID: nid, dbStatus: node?.status ?? "missing" }) - } - // In stepMode, checkCompletion (which can trigger autonomous - // fail/complete) still runs, but spawnReady is skipped — - // stepping must NOT auto-advance after a node fails. - yield* checkCompletion(dagID) - }), - ) - yield* tryDeliverWake(entry.parentSessionID).pipe(Effect.ignore, Effect.forkScoped) - }).pipe(guarded("NodeFailed")), + Stream.runForEach((evt) => + Effect.gen(function* () { + const dagID = evt.data.dagID as string + const entry = runtimes.get(dagID) + if (!entry) return + yield* entry.evalLock.withPermits(1)( + Effect.gen(function* () { + const nid = evt.data.nodeID as string + // Generation arbitration via DB status: each projector runs + // INSIDE the durable publish transaction (core/dag/projector.ts), + // so by the time this handler consumes the event the row + // already reflects it. If the row is no longer "failed", a + // later NodeRestarted/replan reset the node — this event + // belongs to a previous generation and must not touch the + // new one (including the fiber map, which may already hold + // the new attempt's fiber). No generation field needed. + const node = yield* store.getNode(dagID, nid) + // #3: only markUnsatisfied if the runtime still tracks this + // node as non-terminal. A stale NodeFailed event (e.g. from + // a replan-ceiling check after the node already completed) + // would incorrectly flip a satisfied node to unsatisfied. + if (node?.status === "failed" && entry.runtime.isActive(nid)) { + const fiber = entry.fibers.get(nid) + const watcher = entry.watchers.get(nid) + entry.fibers.delete(nid) + entry.watchers.delete(nid) + yield* abortChild(nid, node.childSessionId ?? null).pipe(Effect.ignore) + if (fiber) yield* Fiber.interrupt(fiber).pipe(Effect.ignore) + if (watcher) yield* Fiber.interrupt(watcher).pipe(Effect.ignore) + entry.runtime.markUnsatisfied(nid) + if (!entry.runtime.isStepMode()) yield* spawnReady(dagID) + } + if (node?.status !== "failed") { + yield* Effect.logDebug("DagLoop dropped stale NodeFailed", { + dagID, + nodeID: nid, + dbStatus: node?.status ?? "missing", + }) + } + // In stepMode, checkCompletion (which can trigger autonomous + // fail/complete) still runs, but spawnReady is skipped — + // stepping must NOT auto-advance after a node fails. + yield* checkCompletion(dagID) + }), + ) + yield* tryDeliverWake(entry.parentSessionID).pipe(Effect.ignore, Effect.forkScoped) + }).pipe(guarded("NodeFailed")), ), Effect.forkScoped({ startImmediately: true }), ) @@ -776,7 +807,8 @@ export const layer = Layer.effect( if (node.status !== "running") continue const frag = newConfig?.nodes.find((candidate) => candidate.id === node.id) if (!frag) continue - const oldTimeoutMs = oldConfig?.nodes.find((candidate) => candidate.id === node.id)?.worker_config?.timeout_ms + const oldTimeoutMs = oldConfig?.nodes.find((candidate) => candidate.id === node.id)?.worker_config + ?.timeout_ms const fragTimeoutMs = frag.worker_config?.timeout_ms // §3.7: re-time only when the replan carries a NEW // timeout_ms. The persisted config behind WorkflowReplanned @@ -814,9 +846,10 @@ export const layer = Layer.effect( // by the deadlineElapsed case on the public path and is a // no-op there (cons-F1). if ( - (!node.escalationPending && node.deadlineMs != null && node.deadlineMs > now) - || (node.escalationPending && !node.wakeReported) - ) continue + (!node.escalationPending && node.deadlineMs != null && node.deadlineMs > now) || + (node.escalationPending && !node.wakeReported) + ) + continue // N1: write the new deadline FIRST. nodeExtendTimeout // acquires the workflow lock and can fail or block; if the // write never lands, the old watcher must keep supervising @@ -834,15 +867,18 @@ export const layer = Layer.effect( // a structural check, whereas Cause.interruptors collects // only DEFINED fiber IDs and ignores interrupt reasons // carrying none — those would be swallowed as errors here. - const written = yield* dag.nodeExtendTimeout(dagID, node.id, now + fragTimeoutMs).pipe( - Effect.catchCause((cause) => - Cause.hasInterrupts(cause) - ? Effect.failCause(cause) - : Effect.logWarning("DagLoop replan re-time failed; keeping the old watcher and continuing the batch", { dagID, nodeID: node.id, cause }).pipe( - Effect.as(-1), - ), - ), - ) + const written = yield* dag + .nodeExtendTimeout(dagID, node.id, now + fragTimeoutMs) + .pipe( + Effect.catchCause((cause) => + Cause.hasInterrupts(cause) + ? Effect.failCause(cause) + : Effect.logWarning( + "DagLoop replan re-time failed; keeping the old watcher and continuing the batch", + { dagID, nodeID: node.id, cause }, + ).pipe(Effect.as(-1)), + ), + ) // Negative verdict: -1 (write failure, mapped above) OR -2 // (Q2 delivery-gate rejection — the node is STILL RUNNING but // its escalation wake was undelivered, raced in by the watchdog @@ -864,7 +900,10 @@ export const layer = Layer.effect( const deadWatcher = entry.watchers.get(node.id) if (deadWatcher) yield* Fiber.interrupt(deadWatcher).pipe(Effect.ignore) entry.watchers.delete(node.id) - yield* Effect.logWarning("DagLoop replan re-time skipped — node no longer running", { dagID, nodeID: node.id }) + yield* Effect.logWarning("DagLoop replan re-time skipped — node no longer running", { + dagID, + nodeID: node.id, + }) continue } // Write committed: install the re-armed watcher BEFORE @@ -882,7 +921,8 @@ export const layer = Layer.effect( dagID, nodeID: node.id, timeoutMs: fragTimeoutMs, - maxTimeoutExtensions: newConfig?.max_timeout_extensions ?? Dag.DEFAULT_WORKFLOW_CONFIG.maxTimeoutExtensions, + maxTimeoutExtensions: + newConfig?.max_timeout_extensions ?? Dag.DEFAULT_WORKFLOW_CONFIG.maxTimeoutExtensions, }).pipe( Effect.provideService(Dag.Service, dag), Effect.provideService(SessionPrompt.Service, promptSvc), @@ -891,7 +931,11 @@ export const layer = Layer.effect( const oldWatcher = entry.watchers.get(node.id) entry.watchers.set(node.id, newWatcher) if (oldWatcher) yield* Fiber.interrupt(oldWatcher).pipe(Effect.ignore) - yield* Effect.logInfo("DagLoop extended node deadline via replan", { dagID, nodeID: node.id, newDeadlineMs: now + fragTimeoutMs }) + yield* Effect.logInfo("DagLoop extended node deadline via replan", { + dagID, + nodeID: node.id, + newDeadlineMs: now + fragTimeoutMs, + }) } // Replan resets restarted nodes to pending. Old fibers of nodes // that are no longer running/queued must be interrupted here: @@ -959,11 +1003,9 @@ export const layer = Layer.effect( // already idle (P1-2 fix). const readWakeBatch = Effect.fn("DagLoop.readWakeBatch")(function* (sessionID: string) { - const snapshot = yield* store.getWakeSnapshot(sessionID).pipe( - Effect.catch(() => - Effect.succeed({ nodes: [], workflows: [] } satisfies DagStore.WakeSnapshot), - ), - ) + const snapshot = yield* store + .getWakeSnapshot(sessionID) + .pipe(Effect.catch(() => Effect.succeed({ nodes: [], workflows: [] } satisfies DagStore.WakeSnapshot))) const terminalWorkflows = snapshot.workflows.filter( (workflow) => !workflow.wakeReported && isWorkflowTerminalStatus(workflow.status as never), ) @@ -979,13 +1021,18 @@ export const layer = Layer.effect( // override the delivery boundary for the rest of the attempt. const escalatedWorkflowIDs = new Set( snapshot.nodes - .filter((node) => node.escalationPending || (node.timeoutExtensions > 0 && isNodeTerminalStatus(node.status as never))) + .filter( + (node) => + node.escalationPending || (node.timeoutExtensions > 0 && isNodeTerminalStatus(node.status as never)), + ) .map((node) => node.workflowId), ) - const workflowIDs = [...new Set([ - ...snapshot.nodes.map((node) => node.workflowId), - ...terminalWorkflows.map((workflow) => workflow.id), - ])] + const workflowIDs = [ + ...new Set([ + ...snapshot.nodes.map((node) => node.workflowId), + ...terminalWorkflows.map((workflow) => workflow.id), + ]), + ] const workflowsByID = new Map(snapshot.workflows.map((workflow) => [workflow.id, workflow])) const workflows = workflowIDs.map((workflowID) => workflowsByID.get(workflowID)) const boundaryWorkflows = workflows.filter((workflow): workflow is DagStore.WorkflowRow => { @@ -1021,9 +1068,7 @@ export const layer = Layer.effect( boundaryWorkflows .filter((workflow) => { const entry = runtimes.get(workflow.id) - return workflow.status === "running" - && !entry?.runtime.isPaused() - && !entry?.runtime.isStepMode() + return workflow.status === "running" && !entry?.runtime.isPaused() && !entry?.runtime.isStepMode() }) .map((workflow) => workflow.id), ), @@ -1076,13 +1121,13 @@ export const layer = Layer.effect( yield* entry.evalLock.withPermits(1)( Effect.gen(function* () { const shouldFail = - !entry.runtime.isPaused() - && !entry.runtime.isStepMode() + !entry.runtime.isPaused() && + !entry.runtime.isStepMode() && // Suppress the net only when current-process execution // ownership proves that a running node is making progress. - && !entry.runtime.hasRunningMatching((id) => entry.fibers.has(id)) - && entry.runtime.getReadyNodes().length === 0 - && !entry.runtime.isComplete() + !entry.runtime.hasRunningMatching((id) => entry.fibers.has(id)) && + entry.runtime.getReadyNodes().length === 0 && + !entry.runtime.isComplete() if (shouldFail) yield* dag.fail(dagID, "orchestrator_unresponsive").pipe(Effect.ignore) }), ) @@ -1094,7 +1139,9 @@ export const layer = Layer.effect( if ((yield* statusSvc.get(SessionID.make(sessionID))).type !== "idle") return // Preemption guard (task 3.3): abort if fresher user message exists - const msgs = yield* sessionSvc.messages({ sessionID: SessionID.make(sessionID), limit: 20 }).pipe(Effect.catch(() => Effect.succeed([]))) + const msgs = yield* sessionSvc + .messages({ sessionID: SessionID.make(sessionID), limit: 20 }) + .pipe(Effect.catch(() => Effect.succeed([]))) let lastUserAt = -1 let lastAsstAt = -1 for (const m of msgs) { @@ -1109,10 +1156,18 @@ export const layer = Layer.effect( for (const workflow of batch.workflows) { if (workflow.status !== "failed") continue const failedNodes = yield* store.getNodes(workflow.id).pipe( - Effect.map((nodes) => nodes.filter((node): node is DagStore.NodeRow & { errorClass: string } => node.status === "failed" && node.errorClass !== null)), + Effect.map((nodes) => + nodes.filter( + (node): node is DagStore.NodeRow & { errorClass: string } => + node.status === "failed" && node.errorClass !== null, + ), + ), Effect.catchCause((cause) => Effect.gen(function* () { - yield* Effect.logWarning("wake digest failed to read failed nodes", { workflowId: workflow.id, cause }) + yield* Effect.logWarning("wake digest failed to read failed nodes", { + workflowId: workflow.id, + cause, + }) return [] as (DagStore.NodeRow & { errorClass: string })[] }), ), @@ -1120,7 +1175,9 @@ export const layer = Layer.effect( if (failedNodes.length > 0) { failuresByWorkflow.set( workflow.id, - failedNodes.map((node) => `- "${node.name}" (${node.errorClass}): ${node.errorReason ?? "unknown error"}`.slice(0, 300)), + failedNodes.map((node) => + `- "${node.name}" (${node.errorClass}): ${node.errorReason ?? "unknown error"}`.slice(0, 300), + ), ) } } @@ -1134,11 +1191,17 @@ export const layer = Layer.effect( if (node.status === "running" && node.escalationPending) { return `[DAG Node Timeout] RUNNING node "${node.name}" exceeded its execution deadline (timeout escalation ${node.timeoutExtensions}) and is still executing. Adjudicate by replanning with a NEW worker_config.timeout_ms to extend the node — that grants more execution time, but the cumulative extension count is NOT reset (only a new attempt resets it), and the node is force-cancelled once the cap is reached — or cancel/replan the node. Queued nodes are not extended: their admission deadline was fixed at permit acquisition and is not adjusted by extensions.` } - const output = typeof node.output === "string" - ? node.output.slice(0, 500) - : node.errorReason ?? (node.output == null ? "(no output)" : JSON.stringify(node.output).slice(0, 500)) + const durableResult = + typeof node.output === "string" + ? node.output + : (node.errorReason ?? (node.output == null ? "(no output)" : JSON.stringify(node.output))) + const truncated = durableResult.length > 500 + const output = durableResult.slice(0, 500) const failureClass = node.status === "failed" && node.errorClass ? ` (${node.errorClass})` : "" - return `[DAG Node Result] Node "${node.name}" ${node.status}${failureClass}: ${output}` + const retrieval = truncated + ? `\nComplete output: call workflow result with workflow_id="${node.workflowId}" and node_id="${node.id}".` + : "" + return `[DAG Node Result] Node "${node.name}" ${node.status}${failureClass}: ${output}\n[DAG Result Reference] workflow_id="${node.workflowId}" node_id="${node.id}" truncated=${truncated}${retrieval}` }), ...batch.workflows.map((workflow) => { const failures = failuresByWorkflow.get(workflow.id) @@ -1149,7 +1212,9 @@ export const layer = Layer.effect( const summary = [ ...summaries, ...(plan.actionableDagIDs.size > 0 - ? ['You MUST act on these workflows in this turn (workflow tool: extend / control replan / complete / cancel). If this turn ends with a workflow stalled and no action taken, it will be failed with reason "orchestrator_unresponsive".'] + ? [ + 'You MUST act on these workflows in this turn (workflow tool: extend / control replan / complete / cancel). If this turn ends with a workflow stalled and no action taken, it will be failed with reason "orchestrator_unresponsive".', + ] : []), ].join("\n\n") @@ -1160,28 +1225,32 @@ export const layer = Layer.effect( // receives the node result and can act) but NOT rendered as a user // message in the TUI chat — DAG data surfaces via the sidebar panel // and Inspector, keeping the chat conversation clean. - const didDeliver = yield* promptSvc.promptIfIdle({ - sessionID: SessionID.make(sessionID), - parts: [{ type: "text", text: summary, synthetic: true }], - }).pipe( - Effect.flatMap(Option.match({ - onNone: () => Effect.succeed(false), - onSome: () => - store.markWakeBatchReported(batch).pipe( - Effect.tap(() => - Effect.sync(() => { - plan.unresponsiveDagIDs.forEach((workflowID) => - deliveredUnresponsiveDagIDs.add(workflowID), - ) - }), - ), - Effect.as(true), - ), - })), - Effect.catchCause(() => - Effect.logWarning("DAG wake delivery failed", { sessionID }).pipe(Effect.as(false)), - ), - ) + const didDeliver = yield* promptSvc + .promptIfIdle({ + sessionID: SessionID.make(sessionID), + parts: [{ type: "text", text: summary, synthetic: true }], + }) + .pipe( + Effect.flatMap( + Option.match({ + onNone: () => Effect.succeed(false), + onSome: () => + store.markWakeBatchReported(batch).pipe( + Effect.tap(() => + Effect.sync(() => { + plan.unresponsiveDagIDs.forEach((workflowID) => + deliveredUnresponsiveDagIDs.add(workflowID), + ) + }), + ), + Effect.as(true), + ), + }), + ), + Effect.catchCause(() => + Effect.logWarning("DAG wake delivery failed", { sessionID }).pipe(Effect.as(false)), + ), + ) if (!didDeliver) return } } finally { @@ -1234,25 +1303,29 @@ export const layer = Layer.effect( // absorb them with a warning so layer construction survives, but never // silently — a swallowed failure means wake redelivery is lost until // the next process restart. - const pendingWakeSessions = yield* store.getSessionsWithUnreportedWakes().pipe( - Effect.catchCause((cause) => - Effect.logWarning("DagLoop failed to list sessions with unreported wakes", { cause }).pipe( - Effect.as([] as string[]), + const pendingWakeSessions = yield* store + .getSessionsWithUnreportedWakes() + .pipe( + Effect.catchCause((cause) => + Effect.logWarning("DagLoop failed to list sessions with unreported wakes", { cause }).pipe( + Effect.as([] as string[]), + ), ), - ), - ) + ) for (const sessionID of pendingWakeSessions) { // Cross-instance guard: wake redelivery is store-global. A session's // workflows share its project (enforced at dag.create), so the wake // snapshot's own workflow rows carry the ownership proof — only // drain sessions whose unreported workflows belong to this project. - const snapshot = yield* store.getWakeSnapshot(sessionID).pipe( - Effect.catchCause((cause) => - Effect.logWarning("DagLoop failed to read wake snapshot", { sessionID, cause }).pipe( - Effect.as({ nodes: [], workflows: [] } satisfies DagStore.WakeSnapshot), + const snapshot = yield* store + .getWakeSnapshot(sessionID) + .pipe( + Effect.catchCause((cause) => + Effect.logWarning("DagLoop failed to read wake snapshot", { sessionID, cause }).pipe( + Effect.as({ nodes: [], workflows: [] } satisfies DagStore.WakeSnapshot), + ), ), - ), - ) + ) if (!snapshot.workflows.some((wf) => wf.projectId === ctx.project.id)) continue yield* tryDeliverWake(sessionID).pipe(Effect.forkScoped) } diff --git a/packages/opencode/src/session/tools.ts b/packages/opencode/src/session/tools.ts index 252afaf725..bb2670fd8a 100644 --- a/packages/opencode/src/session/tools.ts +++ b/packages/opencode/src/session/tools.ts @@ -12,7 +12,7 @@ import { MemorySearch } from "@/tool/memory-search" import { Truncate } from "@/tool/truncate" import { Plugin } from "@/plugin" -import type { TaskPromptOps } from "@/tool/task" +import { TaskTool, type TaskPromptOps } from "@/tool/task" import { SettingsHook, type TriggerResult } from "@/hook/settings" import { applyPreHookDecision, classifyPermissionAsk } from "@/hook/pre-hook-decision" import { type Tool as AITool, tool, jsonSchema, type ToolExecutionOptions, asSchema } from "ai" @@ -43,7 +43,7 @@ const SUPPORTED_MCP_RESOURCE_ATTACHMENT_MIMES = new Set([ ]) // Tools that modify files on disk — trigger FileChanged hook after execution const FILE_CHANGING_TOOLS = new Set(["edit", "write", "apply_patch", "multiedit", "patch"]) -const ROOT_ONLY_TOOLS = new Set([MemorySearch.MemorySearchTool.id, "workflow"]) +const ROOT_ONLY_TOOLS = new Set([MemorySearch.MemorySearchTool.id, TaskTool.id, "workflow"]) export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { agent: Agent.Info @@ -121,133 +121,170 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { SessionContext.run(context(args, options).sessionID, () => Effect.gen(function* () { const ctx = context(args, options) - yield* plugin.trigger( - "tool.execute.before", - { tool: item.id, sessionID: ctx.sessionID, callID: ctx.callID }, - { args }, - ) - // SettingsHook PreToolUse - let preContexts: string[] = [] - if (settingsHook) { - const preResult = yield* settingsHook - .trigger( - { event: "PreToolUse", toolName: item.id, toolInput: toRecord(args), toolUseID: ctx.callID }, - { sessionID: ctx.sessionID, transcriptPath: "" }, - ) - .pipe(Effect.catch(() => Effect.succeed({ additionalContexts: [], systemMessages: [] }))) - yield* SettingsHook.landSystemMessages(preResult, { sessionID: ctx.sessionID }) - const decision = applyPreHookDecision(toRecord(args), preResult) - if (decision.deniedReason) { - return { output: `[Tool denied by hook] ${decision.deniedReason}`, attachments: [], metadata: { hookDenied: true } } as any - } - if (decision.stopReason) { - return { output: `[Hook stopped] ${decision.stopReason}`, attachments: [], metadata: { hookStopped: true } } as any - } - // permissionDecision:"ask" — invoke the confirmation dialog. We call - // permission.ask directly (NOT the orDie-piped ctx.ask) and classify the - // outcome: typed rejections become a denied result, while interrupts - // (session abort mid-dialog) and defects propagate instead of being - // masked as a denial. - if (preResult.permissionDecision === "ask") { - const askReason = preResult.permissionDecisionReason - const verdict = yield* permission - .ask({ - permission: item.id, - sessionID: ctx.sessionID, - patterns: [item.id], - always: [], - metadata: { hookAsk: true, ...(askReason ? { reason: askReason } : {}) }, - tool: { messageID: input.processor.message.id, callID: options.toolCallId }, - ruleset: [], - }) - .pipe(Effect.exit) - const outcome = classifyPermissionAsk(verdict) - if (outcome !== "approved" && outcome !== "denied") return yield* Effect.failCause(outcome.propagate as never) - if (outcome === "denied") { - const reason = askReason ?? "Denied by user in hook confirmation" - return { output: `[Tool denied by hook] ${reason}`, attachments: [], metadata: { hookDenied: true } } as any + yield* plugin.trigger( + "tool.execute.before", + { tool: item.id, sessionID: ctx.sessionID, callID: ctx.callID }, + { args }, + ) + // SettingsHook PreToolUse + let preContexts: string[] = [] + if (settingsHook) { + const preResult = yield* settingsHook + .trigger( + { event: "PreToolUse", toolName: item.id, toolInput: toRecord(args), toolUseID: ctx.callID }, + { sessionID: ctx.sessionID, transcriptPath: "" }, + ) + .pipe( + Effect.catch(() => Effect.succeed({ additionalContexts: [], systemMessages: [] })), + ) + yield* SettingsHook.landSystemMessages(preResult, { sessionID: ctx.sessionID }) + const decision = applyPreHookDecision(toRecord(args), preResult) + if (decision.deniedReason) { + return { + output: `[Tool denied by hook] ${decision.deniedReason}`, + attachments: [], + metadata: { hookDenied: true }, + } as any + } + if (decision.stopReason) { + return { + output: `[Hook stopped] ${decision.stopReason}`, + attachments: [], + metadata: { hookStopped: true }, + } as any + } + // permissionDecision:"ask" — invoke the confirmation dialog. We call + // permission.ask directly (NOT the orDie-piped ctx.ask) and classify the + // outcome: typed rejections become a denied result, while interrupts + // (session abort mid-dialog) and defects propagate instead of being + // masked as a denial. + if (preResult.permissionDecision === "ask") { + const askReason = preResult.permissionDecisionReason + const verdict = yield* permission + .ask({ + permission: item.id, + sessionID: ctx.sessionID, + patterns: [item.id], + always: [], + metadata: { hookAsk: true, ...(askReason ? { reason: askReason } : {}) }, + tool: { messageID: input.processor.message.id, callID: options.toolCallId }, + ruleset: [], + }) + .pipe(Effect.exit) + const outcome = classifyPermissionAsk(verdict) + if (outcome !== "approved" && outcome !== "denied") + return yield* Effect.failCause(outcome.propagate as never) + if (outcome === "denied") { + const reason = askReason ?? "Denied by user in hook confirmation" + return { + output: `[Tool denied by hook] ${reason}`, + attachments: [], + metadata: { hookDenied: true }, + } as any + } } + preContexts = preResult.additionalContexts ?? [] + // effectiveArgs reflects any PreToolUse updatedInput rewrite (shallow merge). + args = decision.effectiveArgs } - preContexts = preResult.additionalContexts ?? [] - // effectiveArgs reflects any PreToolUse updatedInput rewrite (shallow merge). - args = decision.effectiveArgs - } - const result = yield* Effect.suspend(() => { - const cleanup = setActiveElicitationSession(ctx.sessionID) - return item.execute(args, ctx).pipe(Effect.ensuring(Effect.sync(cleanup))) - }) - const output = { - ...result, - attachments: result.attachments?.map((attachment) => ({ - ...attachment, - id: PartID.ascending(), - sessionID: ctx.sessionID, - messageID: input.processor.message.id, - })), - } - // PreToolUse additionalContexts: prepend so the model sees any hook-injected - // gate/reminder before the tool result (mirrors PostToolUse surfacing below). - if (preContexts.length) { - output.output = `${preContexts.join("\n\n")}\n\n${output.output ?? ""}` - } - yield* plugin.trigger( - "tool.execute.after", - { tool: item.id, sessionID: ctx.sessionID, callID: ctx.callID, args }, - output, - ) - // SettingsHook PostToolUse - if (settingsHook) { - const postResult = yield* settingsHook - .trigger( - { event: "PostToolUse", toolName: item.id, toolInput: toRecord(args), toolResponse: output.output, toolUseID: ctx.callID } as any, - { sessionID: ctx.sessionID, transcriptPath: "" }, - ) - .pipe(Effect.catch(() => Effect.succeed({ additionalContexts: [] as string[] } as any))) - yield* SettingsHook.landSystemMessages(postResult as TriggerResult, { sessionID: ctx.sessionID }) - // Inject additionalContext into tool output so model sees it - if ((postResult as any).additionalContexts?.length) { - output.output += "\n\n" + (postResult as any).additionalContexts.join("\n") + const result = yield* Effect.suspend(() => { + const cleanup = setActiveElicitationSession(ctx.sessionID) + return item.execute(args, ctx).pipe(Effect.ensuring(Effect.sync(cleanup))) + }) + const output = { + ...result, + attachments: result.attachments?.map((attachment) => ({ + ...attachment, + id: PartID.ascending(), + sessionID: ctx.sessionID, + messageID: input.processor.message.id, + })), } - // PostToolUse preventContinuation: tool already executed, so annotate - // the output rather than skipping. Soft signal, mirrors CC semantics. - if ((postResult as any).preventContinuation) { - const stopReason = (postResult as any).stopReason ?? "Hook requested stop" - output.output += `\n\n[Hook stopped] ${stopReason}` + // PreToolUse additionalContexts: prepend so the model sees any hook-injected + // gate/reminder before the tool result (mirrors PostToolUse surfacing below). + if (preContexts.length) { + output.output = `${preContexts.join("\n\n")}\n\n${output.output ?? ""}` } - } - // SettingsHook FileChanged for file-modifying tools - if (settingsHook && FILE_CHANGING_TOOLS.has(item.id)) { - const fileResult = yield* settingsHook - .trigger( - { event: "FileChanged", path: (toRecord(args))["file_path"] ?? (toRecord(args))["path"], changeType: item.id } as any, - { sessionID: ctx.sessionID, transcriptPath: "" }, - ) - .pipe(Effect.catch(() => Effect.succeed({ additionalContexts: [], systemMessages: [] } as TriggerResult))) - yield* SettingsHook.landSystemMessages(fileResult, { sessionID: ctx.sessionID }) - } - if (options.abortSignal?.aborted) { - yield* input.processor.completeToolCall(options.toolCallId, output) - } - return output - }).pipe( - Effect.catch((error: unknown) => - Effect.gen(function* () { - // SettingsHook PostToolUseFailure + yield* plugin.trigger( + "tool.execute.after", + { tool: item.id, sessionID: ctx.sessionID, callID: ctx.callID, args }, + output, + ) + // SettingsHook PostToolUse if (settingsHook) { - const failResult = yield* settingsHook + const postResult = yield* settingsHook .trigger( - { event: "PostToolUseFailure", toolName: item.id, toolInput: toRecord(args), error: String(error), toolUseID: options.toolCallId } as any, - { sessionID: input.session.id, transcriptPath: "" }, + { + event: "PostToolUse", + toolName: item.id, + toolInput: toRecord(args), + toolResponse: output.output, + toolUseID: ctx.callID, + } as any, + { sessionID: ctx.sessionID, transcriptPath: "" }, ) - .pipe(Effect.catch(() => Effect.succeed({ additionalContexts: [], systemMessages: [] } as TriggerResult))) - yield* SettingsHook.landSystemMessages(failResult, { sessionID: input.session.id }) + .pipe(Effect.catch(() => Effect.succeed({ additionalContexts: [] as string[] } as any))) + yield* SettingsHook.landSystemMessages(postResult as TriggerResult, { sessionID: ctx.sessionID }) + // Inject additionalContext into tool output so model sees it + if ((postResult as any).additionalContexts?.length) { + output.output += "\n\n" + (postResult as any).additionalContexts.join("\n") + } + // PostToolUse preventContinuation: tool already executed, so annotate + // the output rather than skipping. Soft signal, mirrors CC semantics. + if ((postResult as any).preventContinuation) { + const stopReason = (postResult as any).stopReason ?? "Hook requested stop" + output.output += `\n\n[Hook stopped] ${stopReason}` + } + } + // SettingsHook FileChanged for file-modifying tools + if (settingsHook && FILE_CHANGING_TOOLS.has(item.id)) { + const fileResult = yield* settingsHook + .trigger( + { + event: "FileChanged", + path: toRecord(args)["file_path"] ?? toRecord(args)["path"], + changeType: item.id, + } as any, + { sessionID: ctx.sessionID, transcriptPath: "" }, + ) + .pipe( + Effect.catch(() => Effect.succeed({ additionalContexts: [], systemMessages: [] } as TriggerResult)), + ) + yield* SettingsHook.landSystemMessages(fileResult, { sessionID: ctx.sessionID }) } - return yield* Effect.fail(error) - }), + if (options.abortSignal?.aborted) { + yield* input.processor.completeToolCall(options.toolCallId, output) + } + return output + }).pipe( + Effect.catch((error: unknown) => + Effect.gen(function* () { + // SettingsHook PostToolUseFailure + if (settingsHook) { + const failResult = yield* settingsHook + .trigger( + { + event: "PostToolUseFailure", + toolName: item.id, + toolInput: toRecord(args), + error: String(error), + toolUseID: options.toolCallId, + } as any, + { sessionID: input.session.id, transcriptPath: "" }, + ) + .pipe( + Effect.catch(() => + Effect.succeed({ additionalContexts: [], systemMessages: [] } as TriggerResult), + ), + ) + yield* SettingsHook.landSystemMessages(failResult, { sessionID: input.session.id }) + } + return yield* Effect.fail(error) + }), + ), ), ), - ), - ) + ) }, }) } @@ -528,12 +565,12 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { { event: "PreToolUse", toolName: key, toolInput: toRecord(args), toolUseID: opts.toolCallId }, { sessionID: ctx.sessionID, transcriptPath: "" }, ) - .pipe(Effect.catch(() => Effect.succeed({ additionalContexts: [], systemMessages: [] }))) - yield* SettingsHook.landSystemMessages(preResult, { sessionID: ctx.sessionID }) - const decision = applyPreHookDecision(toRecord(args), preResult) - if (decision.deniedReason) { - return { content: [{ type: "text", text: `[Tool denied by hook] ${decision.deniedReason}` }] } as any - } + .pipe(Effect.catch(() => Effect.succeed({ additionalContexts: [], systemMessages: [] }))) + yield* SettingsHook.landSystemMessages(preResult, { sessionID: ctx.sessionID }) + const decision = applyPreHookDecision(toRecord(args), preResult) + if (decision.deniedReason) { + return { content: [{ type: "text", text: `[Tool denied by hook] ${decision.deniedReason}` }] } as any + } if (decision.stopReason) { return { content: [{ type: "text", text: `[Hook stopped] ${decision.stopReason}` }] } as any } @@ -553,7 +590,8 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { }) .pipe(Effect.exit) const outcome = classifyPermissionAsk(verdict) - if (outcome !== "approved" && outcome !== "denied") return yield* Effect.failCause(outcome.propagate as never) + if (outcome !== "approved" && outcome !== "denied") + return yield* Effect.failCause(outcome.propagate as never) if (outcome === "denied") { const reason = askReason ?? "Denied by user in hook confirmation" return { content: [{ type: "text", text: `[Tool denied by hook] ${reason}` }] } as any @@ -650,7 +688,13 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { if (settingsHook) { const postResult = yield* settingsHook .trigger( - { event: "PostToolUse", toolName: key, toolInput: toRecord(args), toolResponse: output.output, toolUseID: opts.toolCallId } as any, + { + event: "PostToolUse", + toolName: key, + toolInput: toRecord(args), + toolResponse: output.output, + toolUseID: opts.toolCallId, + } as any, { sessionID: ctx.sessionID, transcriptPath: "" }, ) .pipe(Effect.catch(() => Effect.succeed({ additionalContexts: [] as string[] } as any))) @@ -675,7 +719,13 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { if (settingsHook) { yield* settingsHook .trigger( - { event: "PostToolUseFailure", toolName: key, toolInput: toRecord(args), error: String(error), toolUseID: opts.toolCallId } as any, + { + event: "PostToolUseFailure", + toolName: key, + toolInput: toRecord(args), + error: String(error), + toolUseID: opts.toolCallId, + } as any, { sessionID: input.session.id, transcriptPath: "" }, ) .pipe(Effect.catch(() => Effect.succeed(undefined as any))) diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index c3e1ce1a40..d08a5edefd 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -1,4 +1,4 @@ -import * as Tool from "./tool" +import { Tool } from "./tool" import DESCRIPTION from "./task.txt" import { ToolJsonSchema } from "./json-schema" import { SessionV1 } from "@opencode-ai/core/v1/session" @@ -100,6 +100,12 @@ export const TaskTool = Tool.define( params: Schema.Schema.Type, ctx: Tool.Context, ) { + const parent = yield* sessions.get(ctx.sessionID) + if (parent.parentID) { + return yield* Effect.fail( + new Error("Task delegation is available only to the main conversation, not child agents"), + ) + } const cfg = yield* config.get() const runInBackground = params.background === true if (runInBackground && !flags.experimentalBackgroundSubagents) { @@ -128,7 +134,6 @@ export const TaskTool = Tool.define( const session = params.task_id ? yield* sessions.get(SessionID.make(params.task_id)).pipe(Effect.catchCause(() => Effect.succeed(undefined))) : undefined - const parent = yield* sessions.get(ctx.sessionID) const childPermission = deriveSubagentSessionPermission({ parentSessionPermission: parent.permission ?? [], subagent: next, @@ -396,9 +401,7 @@ export const TaskTool = Tool.define( }, { sessionID: ctx.sessionID, transcriptPath: "" }, ) - .pipe( - Effect.catch(() => Effect.succeed({ additionalContexts: [], systemMessages: [] } as TriggerResult)), - ) + .pipe(Effect.catch(() => Effect.succeed({ additionalContexts: [], systemMessages: [] } as TriggerResult))) // Land any hook systemMessages so they're never silently dropped. yield* SettingsHook.landSystemMessages(stopResult, { sessionID: ctx.sessionID }) if (!stopResult.blocked) { diff --git a/packages/opencode/src/tool/workflow.ts b/packages/opencode/src/tool/workflow.ts index 47485624fa..1553747f50 100644 --- a/packages/opencode/src/tool/workflow.ts +++ b/packages/opencode/src/tool/workflow.ts @@ -1,6 +1,6 @@ -import * as Tool from "./tool" +import { Tool } from "./tool" import { CommandPlugin } from "@opencode-ai/core/plugin/command" -import { Effect, Schema } from "effect" +import { Effect, Option, Schema } from "effect" import { Dag } from "@/dag/dag" import { DagConfig } from "@/dag/config" import { DagWorkflows } from "@/dag/workflows" @@ -19,6 +19,18 @@ import path from "node:path" const id = "workflow" const MAX_WORKFLOW_SPEC_BYTES = 1_000_000 +const DEFAULT_RESULT_PAGE_CHARS = 8_000 +const MAX_RESULT_PAGE_CHARS = 12_000 + +const ResultCursor = Schema.fromJsonString( + Schema.Struct({ + version: Schema.Literal(1), + workflow_id: Schema.String, + node_id: Schema.String, + offset: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)), + }), +) +const decodeResultCursor = Schema.decodeUnknownOption(ResultCursor) // ============================================================================ // Action schemas remain the single validation authority for file and inline input. @@ -133,9 +145,9 @@ const decodeExtendSpec = Schema.decodeUnknownEffect(ExtendSpec) const decodeReplanSpec = Schema.decodeUnknownEffect(ReplanSpec) export const Parameters = Schema.Struct({ - action: Schema.Literals(["start", "extend", "control", "status", "list", "read", "guide"]).annotate({ + action: Schema.Literals(["start", "extend", "control", "status", "result", "list", "read", "guide"]).annotate({ description: - "start: create workflow; extend: add nodes or blocks; control: pause/resume/cancel/replan/step/complete; status: inspect durable state; list: show saved specs; read: inspect one saved spec before retargeting it; guide: load detailed guidance only when needed", + "start: create workflow; extend: add nodes or blocks; control: pause/resume/cancel/replan/step/complete; status: inspect durable state; result: read one durable node output in bounded pages; list: show saved specs; read: inspect one saved spec before retargeting it; guide: load detailed guidance only when needed", }), topic: Schema.optional(Schema.Literals(["blocks", "interface", "policy", "patterns"])).annotate({ description: @@ -155,7 +167,16 @@ export const Parameters = Schema.Struct({ project_id: Schema.optional(Schema.String).annotate({ description: "(start) Optional Project ID; must match the parent session project", }), - workflow_id: Schema.optional(Schema.String).annotate({ description: "(extend/control/status) Target workflow ID" }), + workflow_id: Schema.optional(Schema.String).annotate({ + description: "(extend/control/status/result) Target workflow ID", + }), + node_id: Schema.optional(Schema.String).annotate({ description: "(result) Target durable node ID" }), + cursor: Schema.optional(Schema.String).annotate({ + description: "(result) Opaque continuation cursor returned by the previous page", + }), + limit: Schema.optional(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: MAX_RESULT_PAGE_CHARS }))).annotate({ + description: `(result) Maximum page characters; defaults to ${DEFAULT_RESULT_PAGE_CHARS}, max ${MAX_RESULT_PAGE_CHARS}`, + }), operation: Schema.optional(Schema.Literals(["pause", "resume", "cancel", "replan", "step", "complete"])).annotate({ description: "(control) Operation to perform", }), @@ -165,7 +186,16 @@ export const Parameters = Schema.Struct({ // Tool definition // ============================================================================ -type Metadata = { workflowId?: string; added?: string[]; cancel?: string[]; restart?: string[]; replace?: string[] } +type Metadata = { + workflowId?: string + nodeId?: string + truncated?: boolean + nextCursor?: string + added?: string[] + cancel?: string[] + restart?: string[] + replace?: string[] +} export const WorkflowTool = Tool.define< typeof Parameters, @@ -201,6 +231,17 @@ export const WorkflowTool = Tool.define< new Error("Workflow orchestration is available only to the main conversation, not child agents"), ) } + yield* ctx.ask({ + permission: id, + patterns: [params.action], + always: ["*"], + metadata: { + action: params.action, + ...(params.workflow_id ? { workflow_id: params.workflow_id } : {}), + ...(params.node_id ? { node_id: params.node_id } : {}), + ...(params.operation ? { operation: params.operation } : {}), + }, + }) switch (params.action) { case "guide": { if (!params.topic) { @@ -321,6 +362,74 @@ export const WorkflowTool = Tool.define< metadata: { workflowId: workflow.id } as Metadata, } } + case "result": { + if (!params.workflow_id || !params.node_id) { + return yield* Effect.die(new Error("result requires 'workflow_id' and 'node_id'")) + } + yield* requireOwnedWorkflow(params.workflow_id, ctx.sessionID) + const node = yield* dag.store.getNode(params.workflow_id, params.node_id).pipe(Effect.orDie) + if (!node) { + return yield* Effect.die(new Error(`Workflow node not found: ${params.workflow_id}/${params.node_id}`)) + } + const cursor = params.cursor + ? decodeResultCursor(Buffer.from(params.cursor, "base64url").toString()) + : Option.some({ + version: 1 as const, + workflow_id: params.workflow_id, + node_id: params.node_id, + offset: 0, + }) + if ( + Option.isNone(cursor) || + cursor.value.workflow_id !== params.workflow_id || + cursor.value.node_id !== params.node_id + ) { + return yield* Effect.die(new Error("Invalid or mismatched workflow result cursor")) + } + const durableResult = node.output ?? node.errorReason + const content = + typeof durableResult === "string" + ? durableResult + : durableResult == null + ? "" + : JSON.stringify(durableResult, null, 2) + if (cursor.value.offset > content.length) { + return yield* Effect.die(new Error("Workflow result cursor is beyond the current output")) + } + const pageEnd = resultPageEnd(content, cursor.value.offset, params.limit ?? DEFAULT_RESULT_PAGE_CHARS) + const truncated = pageEnd < content.length + const nextCursor = truncated + ? Buffer.from( + JSON.stringify({ + version: 1, + workflow_id: params.workflow_id, + node_id: params.node_id, + offset: pageEnd, + }), + ).toString("base64url") + : null + return { + title: `Workflow result: ${node.name}`, + output: JSON.stringify( + { + workflow_id: params.workflow_id, + node_id: params.node_id, + status: node.status, + content: content.slice(cursor.value.offset, pageEnd), + truncated, + next_cursor: nextCursor, + }, + null, + 2, + ), + metadata: { + workflowId: params.workflow_id, + nodeId: params.node_id, + truncated, + ...(nextCursor ? { nextCursor } : {}), + } as Metadata, + } + } case "start": { if (params.session_id && params.session_id !== ctx.sessionID) { return yield* Effect.die(new Error("session_id must match the calling session")) @@ -509,6 +618,18 @@ export const WorkflowTool = Tool.define< }), ) +function resultPageEnd(content: string, offset: number, limit: number) { + const end = Math.min(content.length, offset + limit) + if (end >= content.length) return end + const splitsSurrogatePair = + content.charCodeAt(end - 1) >= 0xd800 && + content.charCodeAt(end - 1) <= 0xdbff && + content.charCodeAt(end) >= 0xdc00 && + content.charCodeAt(end) <= 0xdfff + if (!splitsSurrogatePair) return end + return end - offset === 1 ? end + 1 : end - 1 +} + type WorkflowGraphInput = Schema.Schema.Type type NodeSource = Pick diff --git a/packages/opencode/test/dag/blocks.test.ts b/packages/opencode/test/dag/blocks.test.ts index 0089347112..0af491e914 100644 --- a/packages/opencode/test/dag/blocks.test.ts +++ b/packages/opencode/test/dag/blocks.test.ts @@ -68,32 +68,88 @@ describe("workflow blocks", () => { objective: "Review the implementation", blocks: [ { id: "implementation", kind: "coding" }, - { id: "decision", kind: "review", depends_on: ["implementation"] }, + { id: "verification", kind: "verify", depends_on: ["implementation"] }, + { id: "decision", kind: "review", depends_on: ["verification"] }, { id: "report", kind: "synthesize", depends_on: ["decision"] }, ], }) expect(nodes.map((node) => node.id)).toEqual([ "implementation", + "verification", "decision--standards", "decision--intent", "decision", "report", ]) expect(nodes.find((node) => node.id === "decision")).toMatchObject({ - depends_on: ["decision--standards", "decision--intent"], + depends_on: ["decision--standards", "decision--intent", "verification"], required: true, report_to_parent: true, + condition: 'verification.output.verdict == "PASS"', + review: { + phase: "diff", + implementation_node_id: "implementation", + verification_node_id: "verification", + }, + input_mapping: { + implementation_changed_files: "implementation.output.changed_files", + implementation_fingerprint: "implementation.output.fingerprint", + verification: "verification.output", + standards_review: "decision--standards.output", + intent_review: "decision--intent.output", + }, output_schema: { type: "object", properties: { - verdict: { enum: ["ACCEPT", "REVISE", "REJECT", "BLOCKED"] }, + verdict: { enum: ["ACCEPT", "REJECT"] }, + implementation_fingerprint: { type: "string" }, }, }, }) + expect(nodes.find((node) => node.id === "implementation")?.output_schema).toEqual( + expect.objectContaining({ required: expect.arrayContaining(["changed_files", "fingerprint"]) }), + ) + expect(nodes.find((node) => node.id === "verification")?.output_schema).toEqual( + expect.objectContaining({ + required: expect.arrayContaining(["verdict"]), + properties: expect.objectContaining({ verdict: { type: "string", enum: ["PASS", "FAIL"] } }), + }), + ) expect(nodes.find((node) => node.id === "report")?.condition).toBe('decision.output.verdict == "ACCEPT"') }) + it("rejects an implementation review without one verification gate", () => { + expect(() => + DagBlocks.compileWorkflowBlocks({ + objective: "Review current implementation", + blocks: [ + { id: "implementation", kind: "coding" }, + { id: "decision", kind: "review", depends_on: ["implementation"] }, + ], + }), + ).toThrow("requires exactly one verification ancestor") + }) + + it("serializes unordered workspace writers while leaving read-only lanes parallel", () => { + const nodes = DagBlocks.compileWorkflowBlocks({ + objective: "Build two packages from independent evidence", + blocks: [ + { id: "map-a", kind: "explore" }, + { id: "map-b", kind: "explore" }, + { id: "package-a", kind: "coding", depends_on: ["map-a"] }, + { id: "experiment", kind: "prototype", depends_on: ["map-b"] }, + { id: "package-b", kind: "coding", depends_on: ["map-b"] }, + ], + }) + + expect(nodes.find((node) => node.id === "map-a")?.depends_on).toEqual([]) + expect(nodes.find((node) => node.id === "map-b")?.depends_on).toEqual([]) + expect(nodes.find((node) => node.id === "package-a")?.depends_on).toEqual(["map-a"]) + expect(nodes.find((node) => node.id === "experiment")?.depends_on).toEqual(["map-b", "package-a"]) + expect(nodes.find((node) => node.id === "package-b")?.depends_on).toEqual(["map-b", "experiment"]) + }) + it("routes volume blocks to the standard tier and decision blocks to the advanced tier", () => { const nodes = DagBlocks.compileWorkflowBlocks({ objective: "Deliver a reviewed project change", @@ -131,6 +187,19 @@ describe("workflow blocks", () => { decision: "advanced", report: "advanced", }) + expect(Object.fromEntries(nodes.map((node) => [node.id, node.required]))).toEqual({ + map: false, + plan: true, + experiment: false, + "diagnose--evidence": false, + diagnose: true, + build: false, + verify: true, + "decision--standards": false, + "decision--intent": false, + decision: true, + report: true, + }) }) it("rejects ambiguous dependencies and expansion collisions", () => { diff --git a/packages/opencode/test/dag/dag-wake-integration.test.ts b/packages/opencode/test/dag/dag-wake-integration.test.ts index 72ab6b033a..f15be709b1 100644 --- a/packages/opencode/test/dag/dag-wake-integration.test.ts +++ b/packages/opencode/test/dag/dag-wake-integration.test.ts @@ -39,10 +39,12 @@ interface ParentPromptGate { function takeWithin(queue: Queue.Queue, message: string) { return Queue.take(queue).pipe( Effect.timeoutOption("1 second"), - Effect.flatMap(Option.match({ - onNone: () => Effect.fail(new Error(message)), - onSome: Effect.succeed, - })), + Effect.flatMap( + Option.match({ + onNone: () => Effect.fail(new Error(message)), + onSome: Effect.succeed, + }), + ), ) } @@ -63,7 +65,7 @@ function reply(sessionID: string, text: string): SessionV1.WithParts { time: { created: Date.now() }, finish: "stop", }, - parts: text ? [{ type: "text", text }] as never : [], + parts: text ? ([{ type: "text", text }] as never) : [], } } @@ -88,9 +90,7 @@ function promptText(input: SessionPrompt.PromptInput) { function waitForCompletion(store: DagStore.Interface, dagID: string, message: string) { return pollWithTimeout( - store.getWorkflow(dagID).pipe( - Effect.map((workflow) => workflow?.status === "completed" ? true : undefined), - ), + store.getWorkflow(dagID).pipe(Effect.map((workflow) => (workflow?.status === "completed" ? true : undefined))), message, ) } @@ -105,14 +105,8 @@ function wakeLayer(input: { const bridge = EventV2Bridge.layer.pipe(Layer.provide(events)) const store = DagStore.layer.pipe(Layer.provide(database)) const status = SessionStatus.layer.pipe(Layer.provide(bridge)) - const projector = DagProjector.layer.pipe( - Layer.provide(events), - Layer.provide(database), - ) - const dag = Dag.layer.pipe( - Layer.provide(bridge), - Layer.provide(store), - ) + const projector = DagProjector.layer.pipe(Layer.provide(events), Layer.provide(database)) + const dag = Dag.layer.pipe(Layer.provide(bridge), Layer.provide(store)) const base = Layer.mergeAll(database, events, bridge, store, projector, dag, status) const childTitles = new Map() const created: string[] = [] @@ -132,9 +126,7 @@ function wakeLayer(input: { if (sessionID === "ses_parent") { const release = yield* Deferred.make<"success" | "failure">() yield* Queue.offer(input.parentPrompts, { input: value, release }) - const outcome = yield* Deferred.await(release).pipe( - Effect.ensuring(Queue.offer(input.parentSettled, undefined)), - ) + const outcome = yield* Deferred.await(release).pipe(Effect.ensuring(Queue.offer(input.parentSettled, undefined))) if (outcome === "failure") return yield* Effect.die(new Error("provider unavailable")) return reply(sessionID, "parent handled wake") } @@ -152,17 +144,18 @@ function wakeLayer(input: { promptIfIdle: (value) => deliver(value).pipe(Effect.map(Option.some)), }) const agent = Layer.mock(Agent.Service, { - get: () => Effect.succeed({ - name: "build", - mode: "all", - permission: [], - options: {}, - description: "", - prompt: "", - model: { providerID: "test" as never, modelID: "test-model" as never }, - tools: {}, - hooks: {}, - }), + get: () => + Effect.succeed({ + name: "build", + mode: "all", + permission: [], + options: {}, + description: "", + prompt: "", + model: { providerID: "test" as never, modelID: "test-model" as never }, + tools: {}, + hooks: {}, + }), }) const loop = DagLoop.layer.pipe( Layer.provide(base), @@ -183,9 +176,7 @@ function runWakeTest( readonly parentPrompts: Queue.Queue readonly parentSettled: Queue.Queue }) => Effect.Effect, - beforeInit?: (services: { - readonly database: Database.Interface - }) => Effect.Effect, + beforeInit?: (services: { readonly database: Database.Interface }) => Effect.Effect, ) { return Effect.gen(function* () { const childPrompts = yield* Queue.unbounded() @@ -197,19 +188,27 @@ function runWakeTest( const store = yield* DagStore.Service const status = yield* SessionStatus.Service const database = yield* Database.Service - yield* database.db.insert(ProjectTable).values({ - id: "project-1" as never, - worktree: process.cwd() as never, - sandboxes: [], - }).run().pipe(Effect.orDie) - yield* database.db.insert(SessionTable).values({ - id: "ses_parent" as never, - project_id: "project-1" as never, - slug: "parent", - directory: process.cwd() as never, - title: "Parent", - version: "test", - }).run().pipe(Effect.orDie) + yield* database.db + .insert(ProjectTable) + .values({ + id: "project-1" as never, + worktree: process.cwd() as never, + sandboxes: [], + }) + .run() + .pipe(Effect.orDie) + yield* database.db + .insert(SessionTable) + .values({ + id: "ses_parent" as never, + project_id: "project-1" as never, + slug: "parent", + directory: process.cwd() as never, + title: "Parent", + version: "test", + }) + .run() + .pipe(Effect.orDie) if (beforeInit) yield* beforeInit({ database }) yield* loop.init() return yield* test({ dag, loop, store, status, childPrompts, parentPrompts, parentSettled }) @@ -275,9 +274,9 @@ describe("DagLoop atomic wake integration", () => { yield* Deferred.succeed(implement.release, "Implemented") yield* pollWithTimeout( - store.getWorkflow(dagID).pipe( - Effect.map((workflow) => workflow?.status === "completed" ? workflow : undefined), - ), + store + .getWorkflow(dagID) + .pipe(Effect.map((workflow) => (workflow?.status === "completed" ? workflow : undefined))), "deep prompt workflow did not complete", ) }), @@ -317,9 +316,9 @@ describe("DagLoop atomic wake integration", () => { yield* Deferred.succeed(second.release, "done") yield* pollWithTimeout( - store.getWorkflow(dagID).pipe( - Effect.map((workflow) => workflow?.status === "completed" ? workflow : undefined), - ), + store + .getWorkflow(dagID) + .pipe(Effect.map((workflow) => (workflow?.status === "completed" ? workflow : undefined))), "queued-admission workflow did not complete", ) }), @@ -395,9 +394,9 @@ describe("DagLoop atomic wake integration", () => { yield* Deferred.succeed(review.release, "No security issues found.") yield* pollWithTimeout( - store.getWorkflow(dagID).pipe( - Effect.map((workflow) => workflow?.status === "completed" ? workflow : undefined), - ), + store + .getWorkflow(dagID) + .pipe(Effect.map((workflow) => (workflow?.status === "completed" ? workflow : undefined))), "workflow did not complete", ) }), @@ -450,9 +449,9 @@ describe("DagLoop atomic wake integration", () => { yield* Deferred.succeed(arbitrate.release, "Proceed with one review unavailable.") yield* pollWithTimeout( - store.getWorkflow(dagID).pipe( - Effect.map((workflow) => workflow?.status === "completed" ? workflow : undefined), - ), + store + .getWorkflow(dagID) + .pipe(Effect.map((workflow) => (workflow?.status === "completed" ? workflow : undefined))), "workflow did not complete", ) expect((yield* store.getNode(dagID, "review-security"))?.status).toBe("failed") @@ -500,6 +499,60 @@ describe("DagLoop atomic wake integration", () => { ) }) + it("keeps long wake output bounded and identifies the durable result target", async () => { + await Effect.runPromise( + runWakeTest(({ dag, childPrompts, parentPrompts }) => + Effect.gen(function* () { + const dagID = yield* dag.create({ + projectID: "project-1", + sessionID: "ses_parent", + title: "Long result retrieval", + config: { name: "long-result-retrieval", nodes: [node("long-report")] }, + }) + + const report = yield* takeWithin(childPrompts, "long-report did not start") + yield* Deferred.succeed(report.release, `${"a".repeat(1_500)}WAKE_SENTINEL`) + const parent = yield* takeWithin(parentPrompts, "long report did not wake the parent") + const wake = promptText(parent.input) + + expect(wake).toContain(`workflow_id="${dagID}"`) + expect(wake).toContain('node_id="long-report"') + expect(wake).toContain("truncated=true") + expect(wake).toContain("workflow result") + expect(wake).not.toContain("WAKE_SENTINEL") + expect(wake.length).toBeLessThan(1_500) + yield* Deferred.succeed(parent.release, "success") + }), + ), + ) + }) + + it("marks a short wake preview as complete", async () => { + await Effect.runPromise( + runWakeTest(({ dag, childPrompts, parentPrompts }) => + Effect.gen(function* () { + const dagID = yield* dag.create({ + projectID: "project-1", + sessionID: "ses_parent", + title: "Short result retrieval", + config: { name: "short-result-retrieval", nodes: [node("short-report")] }, + }) + + const report = yield* takeWithin(childPrompts, "short-report did not start") + yield* Deferred.succeed(report.release, "complete short output") + const parent = yield* takeWithin(parentPrompts, "short report did not wake the parent") + const wake = promptText(parent.input) + + expect(wake).toContain(`workflow_id="${dagID}"`) + expect(wake).toContain('node_id="short-report"') + expect(wake).toContain("truncated=false") + expect(wake).toContain("complete short output") + yield* Deferred.succeed(parent.release, "success") + }), + ), + ) + }) + integration.live("runs an additive wave after a terminal checkpoint wake", () => runWakeTest(({ dag, store, childPrompts, parentPrompts }) => Effect.gen(function* () { @@ -516,9 +569,9 @@ describe("DagLoop atomic wake integration", () => { const checkpoint = yield* takeWithin(childPrompts, "checkpoint did not start") yield* Deferred.succeed(checkpoint.release, "REVISE") yield* pollWithTimeout( - store.getWorkflow(dagID).pipe( - Effect.map((workflow) => workflow?.status === "completed" ? true : undefined), - ), + store + .getWorkflow(dagID) + .pipe(Effect.map((workflow) => (workflow?.status === "completed" ? true : undefined))), "checkpoint workflow did not complete", ) @@ -533,9 +586,9 @@ describe("DagLoop atomic wake integration", () => { yield* Deferred.succeed(parent.release, "success") yield* Deferred.succeed(repair.release, "fixed") yield* pollWithTimeout( - store.getWorkflow(dagID).pipe( - Effect.map((workflow) => workflow?.status === "completed" ? true : undefined), - ), + store + .getWorkflow(dagID) + .pipe(Effect.map((workflow) => (workflow?.status === "completed" ? true : undefined))), "extended workflow did not complete", ) }), @@ -601,10 +654,11 @@ describe("DagLoop atomic wake integration", () => { yield* Deferred.succeed(downstream.release, "done") yield* waitForCompletion(store, dagID, "workflow did not complete") - const error = yield* dag.extend(dagID, [node("repair", ["checkpoint"])]).pipe( - Effect.catch((cause: Error) => Effect.succeed(cause)), - ) - if (!(error instanceof TerminalViolationError)) throw new Error("extend unexpectedly succeeded past a terminal checkpoint") + const error = yield* dag + .extend(dagID, [node("repair", ["checkpoint"])]) + .pipe(Effect.catch((cause: Error) => Effect.succeed(cause))) + if (!(error instanceof TerminalViolationError)) + throw new Error("extend unexpectedly succeeded past a terminal checkpoint") expect(error.message).toContain("continued past the checkpoint") }), ), @@ -626,16 +680,16 @@ describe("DagLoop atomic wake integration", () => { yield* takeWithin(childPrompts, "checkpoint did not start") yield* dag.complete(dagID) yield* pollWithTimeout( - store.getWorkflow(dagID).pipe( - Effect.map((workflow) => workflow?.status === "completed" ? true : undefined), - ), + store + .getWorkflow(dagID) + .pipe(Effect.map((workflow) => (workflow?.status === "completed" ? true : undefined))), "workflow did not early-complete", ) expect((yield* store.getNode(dagID, "later"))?.errorReason).toBe("agent_complete") - const error = yield* dag.extend(dagID, [node("repair", ["checkpoint"])]).pipe( - Effect.catch((cause: Error) => Effect.succeed(cause)), - ) + const error = yield* dag + .extend(dagID, [node("repair", ["checkpoint"])]) + .pipe(Effect.catch((cause: Error) => Effect.succeed(cause))) expect(error).toBeInstanceOf(TerminalViolationError) }), ), @@ -755,16 +809,16 @@ describe("DagLoop atomic wake integration", () => { const leaf = yield* takeWithin(childPrompts, "leaf did not start") yield* Deferred.succeed(leaf.release, "done") yield* pollWithTimeout( - store.getWorkflow(dagID).pipe( - Effect.map((workflow) => workflow?.status === "completed" ? true : undefined), - ), + store + .getWorkflow(dagID) + .pipe(Effect.map((workflow) => (workflow?.status === "completed" ? true : undefined))), "non-reporting workflow did not complete", ) expect((yield* store.getNode(dagID, "leaf"))?.wakeEligible).toBe(false) - const error = yield* dag.extend(dagID, [node("extra", ["leaf"])]).pipe( - Effect.catch((cause: Error) => Effect.succeed(cause)), - ) + const error = yield* dag + .extend(dagID, [node("extra", ["leaf"])]) + .pipe(Effect.catch((cause: Error) => Effect.succeed(cause))) expect(error).toBeInstanceOf(TerminalViolationError) }), ), @@ -778,22 +832,24 @@ describe("DagLoop atomic wake integration", () => { // (verdict_fail: Unresolved template placeholders). Acceptance-time // binding validation now rejects it before any node can spawn — the // "Added, then spawn-dead" silent window is gone. - const createError = yield* dag.create({ - projectID: "project-1", - sessionID: "ses_parent", - title: "Unresolved aggregate input", - config: { - name: "unresolved-aggregate-input", - nodes: [ - node("node-a"), - { - ...node("summary", ["node-a"]), - input_mapping: {}, - prompt_template: { inline: "汇总结果:{{node-a}}" }, - }, - ], - }, - }).pipe(Effect.catch((cause: Error) => Effect.succeed(cause.message))) + const createError = yield* dag + .create({ + projectID: "project-1", + sessionID: "ses_parent", + title: "Unresolved aggregate input", + config: { + name: "unresolved-aggregate-input", + nodes: [ + node("node-a"), + { + ...node("summary", ["node-a"]), + input_mapping: {}, + prompt_template: { inline: "汇总结果:{{node-a}}" }, + }, + ], + }, + }) + .pipe(Effect.catch((cause: Error) => Effect.succeed(cause.message))) expect(createError).toContain('unbound variable "{{node-a}}"') expect(yield* Queue.poll(childPrompts)).toEqual(Option.none()) }), @@ -835,9 +891,9 @@ describe("DagLoop atomic wake integration", () => { yield* Deferred.succeed(root.release, "A") yield* pollWithTimeout( - store.getNode(dagID, "summary").pipe( - Effect.map((item) => item?.status === "failed" ? item : undefined), - ), + store + .getNode(dagID, "summary") + .pipe(Effect.map((item) => (item?.status === "failed" ? item : undefined))), "summary node did not fail", ) const summary = yield* store.getNode(dagID, "summary") @@ -845,7 +901,9 @@ describe("DagLoop atomic wake integration", () => { expect(summary?.errorClass).toBe("verdict_fail") const parent = yield* takeWithin(parentPrompts, "workflow failure did not wake the parent") const wakeText = promptText(parent.input) - expect(wakeText).toContain('[DAG Workflow failed] Workflow "Unresolved aggregate input" has reached terminal status.') + expect(wakeText).toContain( + '[DAG Workflow failed] Workflow "Unresolved aggregate input" has reached terminal status.', + ) expect(wakeText).toContain('Failed nodes:\n- "summary" (verdict_fail):') yield* Deferred.succeed(parent.release, "success") expect(yield* Queue.poll(childPrompts)).toEqual(Option.none()) @@ -882,10 +940,7 @@ describe("DagLoop atomic wake integration", () => { const parent = yield* takeWithin(parentPrompts, "terminal workflow did not trigger a parent wake") yield* Deferred.succeed(prompts.get("root")!.release, "root result") - const downstream = yield* takeWithin( - childPrompts, - "downstream scheduling waited for the blocked parent wake", - ) + const downstream = yield* takeWithin(childPrompts, "downstream scheduling waited for the blocked parent wake") expect(downstream.title).toBe("downstream") yield* Deferred.succeed(parent.release, "success") @@ -1006,33 +1061,41 @@ describe("DagLoop atomic wake integration", () => { yield* Deferred.succeed(parent.release, "success") }), ({ database }) => - database.db.transaction((tx) => - Effect.gen(function* () { - yield* tx.insert(WorkflowTable).values({ - id: "recovered-workflow", - project_id: "project-1" as never, - session_id: "ses_parent" as never, - title: "Recovered workflow", - status: "completed", - config: "{}", - seq: 10, - wake_reported: false, - }).run() - yield* tx.insert(WorkflowNodeTable).values({ - id: "recovered-node", - workflow_id: "recovered-workflow", - name: "recovered-node", - worker_type: "build", - status: "completed", - required: true, - depends_on: [], - output: "recovered", - wake_eligible: true, - wake_reported: false, - seq: 9, - }).run() - }), - ).pipe(Effect.orDie), + database.db + .transaction((tx) => + Effect.gen(function* () { + yield* tx + .insert(WorkflowTable) + .values({ + id: "recovered-workflow", + project_id: "project-1" as never, + session_id: "ses_parent" as never, + title: "Recovered workflow", + status: "completed", + config: "{}", + seq: 10, + wake_reported: false, + }) + .run() + yield* tx + .insert(WorkflowNodeTable) + .values({ + id: "recovered-node", + workflow_id: "recovered-workflow", + name: "recovered-node", + worker_type: "build", + status: "completed", + required: true, + depends_on: [], + output: "recovered", + wake_eligible: true, + wake_reported: false, + seq: 9, + }) + .run() + }), + ) + .pipe(Effect.orDie), ), ) }) @@ -1051,9 +1114,9 @@ describe("DagLoop atomic wake integration", () => { const child = yield* takeWithin(childPrompts, "busy-parent node did not start") yield* Deferred.succeed(child.release, "held result") yield* pollWithTimeout( - store.getWorkflow(dagID).pipe( - Effect.map((workflow) => workflow?.status === "completed" ? true as const : undefined), - ), + store + .getWorkflow(dagID) + .pipe(Effect.map((workflow) => (workflow?.status === "completed" ? (true as const) : undefined))), "workflow did not complete while its parent was busy", ) @@ -1076,27 +1139,21 @@ describe("DagLoop atomic wake integration", () => { ({ store, childPrompts, parentPrompts }) => Effect.gen(function* () { const responder = yield* Effect.forever( - Queue.take(childPrompts).pipe( - Effect.flatMap((prompt) => Deferred.succeed(prompt.release, "done")), - ), + Queue.take(childPrompts).pipe(Effect.flatMap((prompt) => Deferred.succeed(prompt.release, "done"))), ).pipe(Effect.forkChild) const parent = yield* takeWithin( parentPrompts, "parent agent did not receive the durable DAG status after recovery", ) - expect( - (yield* store.getNode("dag_recovered_conditional", "conditional"))?.status, - ).toBe("skipped") + expect((yield* store.getNode("dag_recovered_conditional", "conditional"))?.status).toBe("skipped") // D13: after-conditional depends only on the skipped conditional // node, so it cascade-skips instead of running on a placeholder // input — the gate rejection blocks the whole downstream subtree. const afterConditional = yield* store.getNode("dag_recovered_conditional", "after-conditional") expect(afterConditional?.status).toBe("skipped") expect(afterConditional?.errorReason).toBe("orphan_cascade") - expect(promptText(parent.input)).toContain( - 'Node "quality-gate" completed: REJECT', - ) + expect(promptText(parent.input)).toContain('Node "quality-gate" completed: REJECT') expect(promptText(parent.input)).toContain( 'Workflow "Recovered conditional workflow" has reached terminal status', ) @@ -1104,73 +1161,81 @@ describe("DagLoop atomic wake integration", () => { yield* Fiber.interrupt(responder) }), ({ database }) => - database.db.transaction((tx) => - Effect.gen(function* () { - yield* tx.insert(WorkflowTable).values({ - id: "dag_recovered_conditional", - project_id: "project-1" as never, - session_id: "ses_parent" as never, - title: "Recovered conditional workflow", - status: "running", - config: JSON.stringify({ - name: "dag_recovered_conditional", - nodes: [ - node("quality-gate"), + database.db + .transaction((tx) => + Effect.gen(function* () { + yield* tx + .insert(WorkflowTable) + .values({ + id: "dag_recovered_conditional", + project_id: "project-1" as never, + session_id: "ses_parent" as never, + title: "Recovered conditional workflow", + status: "running", + config: JSON.stringify({ + name: "dag_recovered_conditional", + nodes: [ + node("quality-gate"), + { + ...node("conditional", ["quality-gate"]), + report_to_parent: false, + condition: 'quality-gate.output.verdict == "ACCEPT"', + }, + { + ...node("after-conditional", ["conditional"]), + report_to_parent: false, + }, + ], + }), + seq: 6, + wake_reported: false, + }) + .run() + yield* tx + .insert(WorkflowNodeTable) + .values([ { - ...node("conditional", ["quality-gate"]), - report_to_parent: false, - condition: 'quality-gate.output.verdict == "ACCEPT"', + id: "quality-gate", + workflow_id: "dag_recovered_conditional", + name: "quality-gate", + worker_type: "build", + status: "completed", + required: true, + depends_on: [], + output: "REJECT", + wake_eligible: true, + wake_reported: false, + seq: 4, }, { - ...node("after-conditional", ["conditional"]), - report_to_parent: false, + id: "conditional", + workflow_id: "dag_recovered_conditional", + name: "conditional", + worker_type: "build", + status: "pending", + required: true, + depends_on: ["quality-gate"], + wake_eligible: false, + wake_reported: false, + seq: 2, }, - ], - }), - seq: 6, - wake_reported: false, - }).run() - yield* tx.insert(WorkflowNodeTable).values([ - { - id: "quality-gate", - workflow_id: "dag_recovered_conditional", - name: "quality-gate", - worker_type: "build", - status: "completed", - required: true, - depends_on: [], - output: "REJECT", - wake_eligible: true, - wake_reported: false, - seq: 4, - }, - { - id: "conditional", - workflow_id: "dag_recovered_conditional", - name: "conditional", - worker_type: "build", - status: "pending", - required: true, - depends_on: ["quality-gate"], - wake_eligible: false, - wake_reported: false, - seq: 2, - }, - { - id: "after-conditional", - workflow_id: "dag_recovered_conditional", - name: "after-conditional", - worker_type: "build", - status: "pending", - required: true, - depends_on: ["conditional"], - wake_eligible: false, - wake_reported: false, - seq: 1, - }, - ]).run() - }), - ).pipe(Effect.orDie), + { + id: "after-conditional", + workflow_id: "dag_recovered_conditional", + name: "after-conditional", + worker_type: "build", + status: "pending", + required: true, + depends_on: ["conditional"], + wake_eligible: false, + wake_reported: false, + seq: 1, + }, + ]) + .run() + }), + ) + .pipe(Effect.orDie), ), ) }) @@ -1181,9 +1246,9 @@ describe("DagLoop atomic wake integration", () => { ({ store, parentPrompts }) => Effect.gen(function* () { const workflow = yield* pollWithTimeout( - store.getWorkflow("dag_recovered_review_rejection").pipe( - Effect.map((row) => row?.status === "failed" ? row : undefined), - ), + store + .getWorkflow("dag_recovered_review_rejection") + .pipe(Effect.map((row) => (row?.status === "failed" ? row : undefined))), "recovered workflow without an accepted review did not fail", ) expect((yield* store.getNode(workflow.id, "review-diff"))?.status).toBe("skipped") @@ -1196,126 +1261,134 @@ describe("DagLoop atomic wake integration", () => { yield* Deferred.succeed(parent.release, "success") }), ({ database }) => - database.db.transaction((tx) => - Effect.gen(function* () { - const nodes = [ - { - ...node("implement"), - output_schema: { - type: "object", - properties: { - diff: { type: "string" }, - fingerprint: { type: "string" }, + database.db + .transaction((tx) => + Effect.gen(function* () { + const nodes = [ + { + ...node("implement"), + output_schema: { + type: "object", + properties: { + diff: { type: "string" }, + fingerprint: { type: "string" }, + }, + required: ["diff", "fingerprint"], }, - required: ["diff", "fingerprint"], }, - }, - { - ...node("verify", ["implement"]), - output_schema: { - type: "object", - properties: { verdict: { enum: ["PASS", "FAIL"] } }, - required: ["verdict"], + { + ...node("verify", ["implement"]), + output_schema: { + type: "object", + properties: { verdict: { enum: ["PASS", "FAIL"] } }, + required: ["verdict"], + }, }, - }, - { - ...node("review-diff", ["verify"]), - worker_type: "review", - review: { - phase: "diff" as const, - implementation_node_id: "implement", - verification_node_id: "verify", + { + ...node("review-diff", ["verify"]), + worker_type: "review", + review: { + phase: "diff" as const, + implementation_node_id: "implement", + verification_node_id: "verify", + }, + input_mapping: { + diff: "implement.output.diff", + implementation_fingerprint: "implement.output.fingerprint", + verification: "verify.output", + }, + condition: 'verify.output.verdict == "PASS"', + output_schema: { + type: "object", + properties: { + verdict: { enum: ["ACCEPT", "REJECT"] }, + implementation_fingerprint: { type: "string" }, + }, + required: ["verdict", "implementation_fingerprint"], + }, }, - input_mapping: { - diff: "implement.output.diff", - implementation_fingerprint: "implement.output.fingerprint", - verification: "verify.output", + { + ...node("final-audit", ["review-diff"]), + worker_type: "audit", + input_mapping: { review: "review-diff.output" }, + condition: 'review-diff.output.verdict == "ACCEPT"', }, - condition: 'verify.output.verdict == "PASS"', - output_schema: { - type: "object", - properties: { - verdict: { enum: ["ACCEPT", "REJECT"] }, - implementation_fingerprint: { type: "string" }, + ] + yield* tx + .insert(WorkflowTable) + .values({ + id: "dag_recovered_review_rejection", + project_id: "project-1" as never, + session_id: "ses_parent" as never, + title: "Recovered review rejection", + status: "running", + config: JSON.stringify({ + name: "dag_recovered_review_rejection", + mode: "deep", + nodes, + }), + seq: 10, + wake_reported: false, + }) + .run() + yield* tx + .insert(WorkflowNodeTable) + .values([ + { + id: "implement", + workflow_id: "dag_recovered_review_rejection", + name: "implement", + worker_type: "build", + status: "completed", + required: true, + depends_on: [], + output: { diff: "diff --git a/a b/a", fingerprint: "fp-1" }, + wake_eligible: false, + wake_reported: true, + seq: 6, }, - required: ["verdict", "implementation_fingerprint"], - }, - }, - { - ...node("final-audit", ["review-diff"]), - worker_type: "audit", - input_mapping: { review: "review-diff.output" }, - condition: 'review-diff.output.verdict == "ACCEPT"', - }, - ] - yield* tx.insert(WorkflowTable).values({ - id: "dag_recovered_review_rejection", - project_id: "project-1" as never, - session_id: "ses_parent" as never, - title: "Recovered review rejection", - status: "running", - config: JSON.stringify({ - name: "dag_recovered_review_rejection", - mode: "deep", - nodes, - }), - seq: 10, - wake_reported: false, - }).run() - yield* tx.insert(WorkflowNodeTable).values([ - { - id: "implement", - workflow_id: "dag_recovered_review_rejection", - name: "implement", - worker_type: "build", - status: "completed", - required: true, - depends_on: [], - output: { diff: "diff --git a/a b/a", fingerprint: "fp-1" }, - wake_eligible: false, - wake_reported: true, - seq: 6, - }, - { - id: "verify", - workflow_id: "dag_recovered_review_rejection", - name: "verify", - worker_type: "build", - status: "completed", - required: true, - depends_on: ["implement"], - output: { verdict: "FAIL" }, - wake_eligible: false, - wake_reported: true, - seq: 5, - }, - { - id: "review-diff", - workflow_id: "dag_recovered_review_rejection", - name: "review-diff", - worker_type: "review", - status: "pending", - required: true, - depends_on: ["verify"], - wake_eligible: false, - wake_reported: false, - seq: 4, - }, - { - id: "final-audit", - workflow_id: "dag_recovered_review_rejection", - name: "final-audit", - worker_type: "audit", - status: "pending", - required: true, - depends_on: ["review-diff"], - wake_eligible: false, - wake_reported: false, - seq: 3, - }, - ]).run() - }), - ).pipe(Effect.orDie), + { + id: "verify", + workflow_id: "dag_recovered_review_rejection", + name: "verify", + worker_type: "build", + status: "completed", + required: true, + depends_on: ["implement"], + output: { verdict: "FAIL" }, + wake_eligible: false, + wake_reported: true, + seq: 5, + }, + { + id: "review-diff", + workflow_id: "dag_recovered_review_rejection", + name: "review-diff", + worker_type: "review", + status: "pending", + required: true, + depends_on: ["verify"], + wake_eligible: false, + wake_reported: false, + seq: 4, + }, + { + id: "final-audit", + workflow_id: "dag_recovered_review_rejection", + name: "final-audit", + worker_type: "audit", + status: "pending", + required: true, + depends_on: ["review-diff"], + wake_eligible: false, + wake_reported: false, + seq: 3, + }, + ]) + .run() + }), + ) + .pipe(Effect.orDie), ), ) }) @@ -1390,9 +1463,9 @@ describe("DagLoop atomic wake integration", () => { // dependency is skipped. Pre-fix, skip ≡ satisfied ran the full // chain and the audit "passed" a rejected gate. yield* pollWithTimeout( - store.getWorkflow(dagID).pipe( - Effect.map((workflow) => workflow?.status === "completed" ? workflow : undefined), - ), + store + .getWorkflow(dagID) + .pipe(Effect.map((workflow) => (workflow?.status === "completed" ? workflow : undefined))), "gated workflow did not complete after the gate rejection", ) const implement = yield* store.getNode(dagID, "implement") @@ -1438,9 +1511,9 @@ describe("DagLoop atomic wake integration", () => { expect((yield* store.getWorkflow(dagID))?.status).toBe("running") yield* Deferred.succeed(b.release, "B done") yield* pollWithTimeout( - store.getWorkflow(dagID).pipe( - Effect.map((workflow) => workflow?.status === "completed" ? workflow : undefined), - ), + store + .getWorkflow(dagID) + .pipe(Effect.map((workflow) => (workflow?.status === "completed" ? workflow : undefined))), "workflow did not complete", ) const parent = yield* takeWithin(parentPrompts, "terminal wake did not reach the parent") diff --git a/packages/opencode/test/dag/workflow-child-tools.test.ts b/packages/opencode/test/dag/workflow-child-tools.test.ts index 48f2278d88..91f57d5e21 100644 --- a/packages/opencode/test/dag/workflow-child-tools.test.ts +++ b/packages/opencode/test/dag/workflow-child-tools.test.ts @@ -25,6 +25,12 @@ const workflowDefinition: Tool.Def = { parameters: Parameters, execute: () => Effect.succeed({ title: "workflow", output: "started", metadata: {} }), } +const taskDefinition: Tool.Def = { + id: "task", + description: "task", + parameters: Parameters, + execute: () => Effect.succeed({ title: "task", output: "started", metadata: {} }), +} const trigger: Plugin.Interface["trigger"] = (_name, _input, output) => Effect.succeed(output) const it = testEffect( Layer.mergeAll( @@ -37,21 +43,22 @@ const it = testEffect( }), Layer.mock(Permission.Service, { ask: () => Effect.void }), Layer.mock(MCP.Service, { clients: () => Effect.succeed({}), tools: () => Effect.succeed({}) }), - Layer.mock(ToolRegistry.Service, { tools: () => Effect.succeed([workflowDefinition]) }), + Layer.mock(ToolRegistry.Service, { tools: () => Effect.succeed([workflowDefinition, taskDefinition]) }), ), ) describe("workflow child boundary", () => { - it.instance("exposes workflow to the main conversation but not to child agents", () => + it.instance("exposes orchestration tools to the main conversation but not to child agents", () => Effect.gen(function* () { const agents = yield* Agent.Service const build = yield* agents.get("build") const parentID = SessionID.make("ses_workflow_tool_parent") + const parentTools = yield* resolvedToolIDs(build, session(parentID)) + const childTools = yield* resolvedToolIDs(build, session(SessionID.make("ses_workflow_tool_child"), parentID)) - expect(yield* resolvedToolIDs(build, session(parentID))).toContain("workflow") - expect(yield* resolvedToolIDs(build, session(SessionID.make("ses_workflow_tool_child"), parentID))).not.toContain( - "workflow", - ) + expect(parentTools).toEqual(expect.arrayContaining(["workflow", "task"])) + expect(childTools).not.toContain("workflow") + expect(childTools).not.toContain("task") }), ) }) diff --git a/packages/opencode/test/dag/workflow-tool.test.ts b/packages/opencode/test/dag/workflow-tool.test.ts index 75910a66aa..c6416ceb09 100644 --- a/packages/opencode/test/dag/workflow-tool.test.ts +++ b/packages/opencode/test/dag/workflow-tool.test.ts @@ -19,6 +19,7 @@ import { fingerprintBrief, type State } from "@/dag/admission" import { ModelV2 } from "@opencode-ai/core/model" import { ProjectV2 } from "@opencode-ai/core/project" import { ProviderV2 } from "@opencode-ai/core/provider" +import { makeNodeRow } from "./fixtures" const projectID = ProjectV2.ID.make("project_test") let workflowSpecDirectory = "" @@ -47,16 +48,14 @@ const admissionBrief = { blocking_questions: [], } -function admissionFor( - verdict: "READY" | "NOT_READY" | "WAIVED", - state: State = verdict, -) { - const brief = verdict === "READY" - ? admissionBrief - : { - ...admissionBrief, - blocking_questions: ["Confirm the production rollout target"], - } +function admissionFor(verdict: "READY" | "NOT_READY" | "WAIVED", state: State = verdict) { + const brief = + verdict === "READY" + ? admissionBrief + : { + ...admissionBrief, + blocking_questions: ["Confirm the production rollout target"], + } return { protocol_version: 1, brief_revision: 1, @@ -87,6 +86,23 @@ function admissionInputFor(verdict: "READY" | "NOT_READY" | "WAIVED") { } const published: Array<{ type: string; data: unknown }> = [] +const resultOutput = `${"a".repeat(1_500)}RESULT_SENTINEL${"b".repeat(200)}` +const resultNodes = [ + makeNodeRow({ + id: "node_result", + workflowId: "dag_result", + name: "Long result", + status: "completed", + output: resultOutput, + }), + makeNodeRow({ + id: "node_other", + workflowId: "dag_result", + name: "Other result", + status: "completed", + output: "other", + }), +] const store = Layer.mock(DagStore.Service, { getWorkflow: (id: string) => Effect.succeed( @@ -105,162 +121,184 @@ const store = Layer.mock(DagStore.Service, { timeCreated: 1, timeUpdated: 2, } - : id === "dag_paused" || id === "dag_step" + : id === "dag_result" ? { id, projectId: projectID, sessionId: "ses_workflow_parent", - title: "Control workflow", - status: id === "dag_paused" ? "paused" : "running", + title: "Result workflow", + status: "completed", config: "{}", seq: 1, - wakeReported: false, - startedAt: 1, - completedAt: null, - timeCreated: 1, - timeUpdated: 2, - } - : id === "dag_deep_status" - ? { - id, - projectId: projectID, - sessionId: "ses_workflow_parent", - title: "Deep status workflow", - status: "running", - config: JSON.stringify({ - name: "deep-status", - mode: "deep", - admission: { - ...admissionFor("WAIVED"), - state: "CONSUMED", - }, - nodes: [], - }), - seq: 1, - wakeReported: false, + wakeReported: true, startedAt: 1, - completedAt: null, + completedAt: 2, timeCreated: 1, timeUpdated: 2, } - : id === "dag_defaults" - ? { - id, - projectId: projectID, - sessionId: "ses_workflow_parent", - title: "Configured defaults", - status: "running", - config: JSON.stringify({ - name: "configured-defaults", - node_defaults: { - required: true, - report_to_parent: true, - worker_config: { timeout_ms: 1234 }, - model: { - providerID: "local-proxy-compatible", - modelID: "local-proxy-compatible/glm-5.2", - }, - }, - max_concurrency: 5, - max_node_replan_attempts: 5, - max_total_nodes: 100, - nodes: [], - }), - seq: 1, - wakeReported: false, - startedAt: 1, - completedAt: null, - timeCreated: 1, - timeUpdated: 2, - } - : undefined, + : id === "dag_paused" || id === "dag_step" + ? { + id, + projectId: projectID, + sessionId: "ses_workflow_parent", + title: "Control workflow", + status: id === "dag_paused" ? "paused" : "running", + config: "{}", + seq: 1, + wakeReported: false, + startedAt: 1, + completedAt: null, + timeCreated: 1, + timeUpdated: 2, + } + : id === "dag_deep_status" + ? { + id, + projectId: projectID, + sessionId: "ses_workflow_parent", + title: "Deep status workflow", + status: "running", + config: JSON.stringify({ + name: "deep-status", + mode: "deep", + admission: { + ...admissionFor("WAIVED"), + state: "CONSUMED", + }, + nodes: [], + }), + seq: 1, + wakeReported: false, + startedAt: 1, + completedAt: null, + timeCreated: 1, + timeUpdated: 2, + } + : id === "dag_defaults" + ? { + id, + projectId: projectID, + sessionId: "ses_workflow_parent", + title: "Configured defaults", + status: "running", + config: JSON.stringify({ + name: "configured-defaults", + node_defaults: { + required: true, + report_to_parent: true, + worker_config: { timeout_ms: 1234 }, + model: { + providerID: "local-proxy-compatible", + modelID: "local-proxy-compatible/glm-5.2", + }, + }, + max_concurrency: 5, + max_node_replan_attempts: 5, + max_total_nodes: 100, + nodes: [], + }), + seq: 1, + wakeReported: false, + startedAt: 1, + completedAt: null, + timeCreated: 1, + timeUpdated: 2, + } + : undefined, ), getNodes: (id: string) => Effect.succeed( id === "dag_status" - ? [{ - id: "node_running", - workflowId: "dag_status", - name: "Running node", - workerType: "build", - status: "running", - required: true, - dependsOn: [], - modelId: null, - modelProviderId: null, - childSessionId: "ses_child", - output: null, - capturedOutput: null, - errorReason: null, - errorClass: null, - deadlineMs: null, - wakeEligible: true, - wakeReported: false, - replanAttempts: 0, - seq: 1, - timeoutExtensions: 0, - escalationPending: false, - startedAt: 1, - completedAt: null, - timeCreated: 1, - timeUpdated: 2, - }, { - id: "node_failed", - workflowId: "dag_status", - name: "Failed node", - workerType: "build", - status: "failed", - required: false, - dependsOn: ["node_running"], - modelId: null, - modelProviderId: null, - childSessionId: "ses_failed_child", - output: null, - capturedOutput: null, - errorReason: "node exceeded timeout of 600000ms", - errorClass: "timeout", - deadlineMs: null, - wakeEligible: false, - wakeReported: false, - replanAttempts: 0, - seq: 2, - timeoutExtensions: 0, - escalationPending: false, - startedAt: 1, - completedAt: 2, - timeCreated: 1, - timeUpdated: 2, - }] - : id === "dag_step" - ? [{ - id: "node_ready", - workflowId: "dag_step", - name: "Ready node", + ? [ + { + id: "node_running", + workflowId: "dag_status", + name: "Running node", workerType: "build", - status: "pending", + status: "running", required: true, dependsOn: [], modelId: null, modelProviderId: null, - childSessionId: null, + childSessionId: "ses_child", output: null, capturedOutput: null, errorReason: null, errorClass: null, deadlineMs: null, - wakeEligible: false, + wakeEligible: true, wakeReported: false, replanAttempts: 0, seq: 1, timeoutExtensions: 0, escalationPending: false, - startedAt: null, + startedAt: 1, completedAt: null, timeCreated: 1, - timeUpdated: 1, - }] - : [], + timeUpdated: 2, + }, + { + id: "node_failed", + workflowId: "dag_status", + name: "Failed node", + workerType: "build", + status: "failed", + required: false, + dependsOn: ["node_running"], + modelId: null, + modelProviderId: null, + childSessionId: "ses_failed_child", + output: null, + capturedOutput: null, + errorReason: "node exceeded timeout of 600000ms", + errorClass: "timeout", + deadlineMs: null, + wakeEligible: false, + wakeReported: false, + replanAttempts: 0, + seq: 2, + timeoutExtensions: 0, + escalationPending: false, + startedAt: 1, + completedAt: 2, + timeCreated: 1, + timeUpdated: 2, + }, + ] + : id === "dag_step" + ? [ + { + id: "node_ready", + workflowId: "dag_step", + name: "Ready node", + workerType: "build", + status: "pending", + required: true, + dependsOn: [], + modelId: null, + modelProviderId: null, + childSessionId: null, + output: null, + capturedOutput: null, + errorReason: null, + errorClass: null, + deadlineMs: null, + wakeEligible: false, + wakeReported: false, + replanAttempts: 0, + seq: 1, + timeoutExtensions: 0, + escalationPending: false, + startedAt: null, + completedAt: null, + timeCreated: 1, + timeUpdated: 1, + }, + ] + : [], ), + getNode: (workflowID: string, nodeID: string) => + Effect.succeed(resultNodes.find((node) => node.workflowId === workflowID && node.id === nodeID)), }) const events = Layer.mock(EventV2Bridge.Service, { publish: (definition, data) => @@ -269,10 +307,7 @@ const events = Layer.mock(EventV2Bridge.Service, { return { id: "event_test", type: definition.type, data } as never }), }) -const dag = Dag.layer.pipe( - Layer.provide(store), - Layer.provide(events), -) +const dag = Dag.layer.pipe(Layer.provide(store), Layer.provide(events)) const runtime = testEffect( Layer.mergeAll( Layer.mock(Agent.Service, { @@ -302,8 +337,7 @@ const runtime = testEffect( slug: "workflow-test", projectID, directory: workflowSpecDirectory, - parentID: - id === SessionID.make("ses_workflow_child") ? SessionID.make("ses_workflow_parent") : undefined, + parentID: id === SessionID.make("ses_workflow_child") ? SessionID.make("ses_workflow_parent") : undefined, title: "Workflow test", version: "test", time: { created: 0, updated: 0 }, @@ -357,9 +391,7 @@ const missingModelRuntime = testEffect( function writeWorkflowSpec(name: string, value: unknown) { const filepath = path.join(workflowSpecDirectory, `${name}.yaml`) - return Effect.promise(() => Bun.write(filepath, JSON.stringify(value, null, 2))).pipe( - Effect.as(filepath), - ) + return Effect.promise(() => Bun.write(filepath, JSON.stringify(value, null, 2))).pipe(Effect.as(filepath)) } function toolContext() { @@ -375,12 +407,15 @@ function toolContext() { } describe("workflow tool schema (negative tests)", () => { - it("action field accepts start/extend/control/status/list/read/guide", () => { + it("action field accepts start/extend/control/status/result/list/read/guide", () => { const decode = Schema.decodeUnknownSync(Parameters) expect(() => decode({ action: "start", spec_path: ".opencode/workflows/test.yaml" })).not.toThrow() - expect(() => decode({ action: "extend", workflow_id: "wf-1", spec_path: ".opencode/workflows/extend.yaml" })).not.toThrow() + expect(() => + decode({ action: "extend", workflow_id: "wf-1", spec_path: ".opencode/workflows/extend.yaml" }), + ).not.toThrow() expect(() => decode({ action: "control", workflow_id: "wf-1", operation: "pause" })).not.toThrow() expect(() => decode({ action: "status", workflow_id: "wf-1" })).not.toThrow() + expect(() => decode({ action: "result", workflow_id: "wf-1", node_id: "node-1", limit: 600 })).not.toThrow() // list browses the saved-spec library and needs no workflow_id. expect(() => decode({ action: "list" })).not.toThrow() expect(() => decode({ action: "read", spec_path: "project-change-route" })).not.toThrow() @@ -430,21 +465,22 @@ describe("workflow tool schema (negative tests)", () => { it("keeps workflow graph and admission fields inside spec", () => { const decode = Schema.decodeUnknownSync(Parameters) - expect(decode({ - action: "start", - spec_path: ".opencode/workflows/deep.yaml", - mode: "deep", - admission: admissionFor("READY", "CONSUMED"), - config: { - name: "deep-schema", - nodes: [], - }, - })).toEqual({ + expect( + decode({ + action: "start", + spec_path: ".opencode/workflows/deep.yaml", + mode: "deep", + admission: admissionFor("READY", "CONSUMED"), + config: { + name: "deep-schema", + nodes: [], + }, + }), + ).toEqual({ action: "start", spec_path: ".opencode/workflows/deep.yaml", }) }) - }) describe("workflow tool execution", () => { @@ -453,10 +489,9 @@ describe("workflow tool execution", () => { published.length = 0 const info = yield* WorkflowTool const workflow = yield* info.init() - const exit = yield* workflow.execute( - { action: "list" }, - { ...toolContext(), sessionID: SessionID.make("ses_workflow_child") }, - ).pipe(Effect.exit) + const exit = yield* workflow + .execute({ action: "list" }, { ...toolContext(), sessionID: SessionID.make("ses_workflow_child") }) + .pipe(Effect.exit) expect(Exit.isFailure(exit)).toBe(true) if (Exit.isFailure(exit)) expect(Cause.pretty(exit.cause)).toContain("main conversation") @@ -469,7 +504,7 @@ describe("workflow tool execution", () => { const info = yield* WorkflowTool const workflow = yield* info.init() - for (const action of ["guide", "start", "extend", "status", "control", "list", "read"]) { + for (const action of ["guide", "start", "extend", "status", "result", "control", "list", "read"]) { expect(workflow.description).toContain(`**${action}**`) } expect(workflow.description).toContain("Do not poll") @@ -521,6 +556,118 @@ describe("workflow tool execution", () => { }), ) + runtime.effect("reads a complete durable node result through target-bound pages", () => + Effect.gen(function* () { + const info = yield* WorkflowTool + const workflow = yield* info.init() + const decode = Schema.decodeUnknownSync(Parameters) + const first = JSON.parse( + (yield* workflow.execute( + decode({ action: "result", workflow_id: "dag_result", node_id: "node_result", limit: 600 }), + toolContext(), + )).output, + ) + const second = JSON.parse( + (yield* workflow.execute( + decode({ + action: "result", + workflow_id: "dag_result", + node_id: "node_result", + cursor: first.next_cursor, + limit: 600, + }), + toolContext(), + )).output, + ) + const third = JSON.parse( + (yield* workflow.execute( + decode({ + action: "result", + workflow_id: "dag_result", + node_id: "node_result", + cursor: second.next_cursor, + limit: 600, + }), + toolContext(), + )).output, + ) + + expect(first).toEqual( + expect.objectContaining({ + workflow_id: "dag_result", + node_id: "node_result", + status: "completed", + truncated: true, + }), + ) + expect(`${first.content}${second.content}${third.content}`).toBe(resultOutput) + expect(third.content).toContain("RESULT_SENTINEL") + expect(third).toEqual(expect.objectContaining({ truncated: false, next_cursor: null })) + + const mismatched = yield* workflow + .execute( + decode({ + action: "result", + workflow_id: "dag_result", + node_id: "node_other", + cursor: first.next_cursor, + }), + toolContext(), + ) + .pipe(Effect.exit) + const malformed = yield* workflow + .execute( + decode({ + action: "result", + workflow_id: "dag_result", + node_id: "node_result", + cursor: "not-a-result-cursor", + }), + toolContext(), + ) + .pipe(Effect.exit) + + expect(Exit.isFailure(mismatched)).toBe(true) + expect(Exit.isFailure(malformed)).toBe(true) + }), + ) + + runtime.effect("requests workflow permission before a control action mutates state", () => + Effect.gen(function* () { + published.length = 0 + const requests: Array<{ permission: string; patterns: readonly string[]; metadata: unknown }> = [] + const info = yield* WorkflowTool + const workflow = yield* info.init() + const exit = yield* workflow + .execute( + { action: "control", workflow_id: "dag_status", operation: "pause" }, + { + ...toolContext(), + ask: (request) => { + requests.push(request) + return Effect.die(new Error("permission denied")) + }, + }, + ) + .pipe(Effect.exit) + + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) expect(Cause.pretty(exit.cause)).toContain("permission denied") + expect(requests).toEqual([ + expect.objectContaining({ + permission: "workflow", + patterns: ["control"], + metadata: expect.objectContaining({ + action: "control", + workflow_id: "dag_status", + operation: "pause", + }), + }), + ]) + expect(published.some((event) => event.type === DagEvent.WorkflowPaused.type)).toBe(false) + }), + ) + runtime.effect("rejects reads and mutations from a session that does not own the workflow", () => Effect.gen(function* () { published.length = 0 @@ -531,28 +678,39 @@ describe("workflow tool execution", () => { sessionID: SessionID.make("ses_foreign"), } satisfies Tool.Context - const statusExit = yield* Effect.exit(workflow.execute( - { action: "status", workflow_id: "dag_status" }, - foreignContext, - )) - const extendExit = yield* Effect.exit(workflow.execute( - { action: "extend", workflow_id: "dag_defaults", spec: { nodes: [] } }, - foreignContext, - )) - const controlExit = yield* Effect.exit(workflow.execute( - { action: "control", workflow_id: "dag_status", operation: "pause" }, - foreignContext, - )) + const statusExit = yield* Effect.exit( + workflow.execute({ action: "status", workflow_id: "dag_status" }, foreignContext), + ) + const resultExit = yield* Effect.exit( + workflow.execute( + Schema.decodeUnknownSync(Parameters)({ + action: "result", + workflow_id: "dag_result", + node_id: "node_result", + }), + foreignContext, + ), + ) + const extendExit = yield* Effect.exit( + workflow.execute({ action: "extend", workflow_id: "dag_defaults", spec: { nodes: [] } }, foreignContext), + ) + const controlExit = yield* Effect.exit( + workflow.execute({ action: "control", workflow_id: "dag_status", operation: "pause" }, foreignContext), + ) expect({ statusSucceeded: Exit.isSuccess(statusExit), statusLeakedChildSession: Exit.isSuccess(statusExit) && statusExit.value.output.includes("ses_child"), + resultSucceeded: Exit.isSuccess(resultExit), + resultLeakedSentinel: Exit.isSuccess(resultExit) && resultExit.value.output.includes("RESULT_SENTINEL"), extendSucceeded: Exit.isSuccess(extendExit), controlSucceeded: Exit.isSuccess(controlExit), publishedPause: published.some((event) => event.type === DagEvent.WorkflowPaused.type), }).toEqual({ statusSucceeded: false, statusLeakedChildSession: false, + resultSucceeded: false, + resultLeakedSentinel: false, extendSucceeded: false, controlSucceeded: false, publishedPause: false, @@ -634,7 +792,8 @@ describe("workflow tool execution", () => { objective: "Implement and review session recovery", blocks: [ { id: "build", kind: "coding", skills: ["tdd"] }, - { id: "review", kind: "review", depends_on: ["build"] }, + { id: "verify", kind: "verify", depends_on: ["build"] }, + { id: "review", kind: "review", depends_on: ["verify"] }, ], }, }, @@ -643,7 +802,7 @@ describe("workflow tool execution", () => { ) expect(result.title).toBe("Workflow started: block-start") - expect(result.output).toContain("4 nodes registered") + expect(result.output).toContain("5 nodes registered") const created = published.find((event) => event.type === DagEvent.WorkflowCreated.type)?.data as { config?: string } @@ -652,6 +811,7 @@ describe("workflow tool execution", () => { expect(config).not.toHaveProperty("objective") expect(config.nodes.map((node: { id: string }) => node.id)).toEqual([ "build", + "verify", "review--standards", "review--intent", "review", @@ -669,13 +829,15 @@ describe("workflow tool execution", () => { action: "extend", workflow_id: "dag_defaults", spec: { - nodes: [{ - id: "inline-added", - name: "Inline added", - worker_type: "general", - depends_on: [], - prompt_template: { inline: "work" }, - }], + nodes: [ + { + id: "inline-added", + name: "Inline added", + worker_type: "general", + depends_on: [], + prompt_template: { inline: "work" }, + }, + ], }, }), toolContext(), @@ -725,13 +887,15 @@ describe("workflow tool execution", () => { spec: { fragment: { name: "inline-replan", - nodes: [{ - id: "inline-replanned", - name: "Inline replanned", - worker_type: "general", - depends_on: [], - prompt_template: { inline: "work" }, - }], + nodes: [ + { + id: "inline-replanned", + name: "Inline replanned", + worker_type: "general", + depends_on: [], + prompt_template: { inline: "work" }, + }, + ], }, }, }), @@ -766,10 +930,9 @@ describe("workflow tool execution", () => { for (const item of cases) { published.length = 0 - const exit = yield* workflow.execute( - Schema.decodeUnknownSync(Parameters)(item.params), - toolContext(), - ).pipe(Effect.exit) + const exit = yield* workflow + .execute(Schema.decodeUnknownSync(Parameters)(item.params), toolContext()) + .pipe(Effect.exit) expect(Exit.isFailure(exit)).toBe(true) if (Exit.isFailure(exit)) expect(Cause.pretty(exit.cause)).toContain(item.message) @@ -799,18 +962,20 @@ describe("workflow tool execution", () => { ) const output = JSON.parse(result.output) - expect(output).toEqual(expect.objectContaining({ - mode: "deep", - admission: { - verdict: "WAIVED", - state: "CONSUMED", - qa_mode: "STANDARD", - brief_revision: 1, - fingerprint: admissionFor("WAIVED").fingerprint, - waiver_reason: "Preview release only", - acknowledged_risks: ["Production rollout is unresolved"], - }, - })) + expect(output).toEqual( + expect.objectContaining({ + mode: "deep", + admission: { + verdict: "WAIVED", + state: "CONSUMED", + qa_mode: "STANDARD", + brief_revision: 1, + fingerprint: admissionFor("WAIVED").fingerprint, + waiver_reason: "Preview release only", + acknowledged_risks: ["Production rollout is unresolved"], + }, + }), + ) expect(output.admission).not.toHaveProperty("qa_transcript") }), ) @@ -846,13 +1011,15 @@ config: metadata: () => Effect.void, ask: () => Effect.void, } satisfies Tool.Context - const invalid = yield* workflow.execute( - { - action: "start", - spec_path: "deep.yaml", - }, - context, - ).pipe(Effect.exit) + const invalid = yield* workflow + .execute( + { + action: "start", + spec_path: "deep.yaml", + }, + context, + ) + .pipe(Effect.exit) expect(Exit.isFailure(invalid)).toBe(true) if (Exit.isFailure(invalid)) { @@ -904,15 +1071,17 @@ config: const created = published.find((event) => event.type === DagEvent.WorkflowCreated.type)?.data as { config?: string } - expect(JSON.parse(created.config ?? "{}")).toEqual(expect.objectContaining({ - mode: "deep", - admission: expect.objectContaining({ - protocol_version: 1, - verdict: "READY", - state: "CONSUMED", - fingerprint: fingerprintBrief(admissionBrief), + expect(JSON.parse(created.config ?? "{}")).toEqual( + expect.objectContaining({ + mode: "deep", + admission: expect.objectContaining({ + protocol_version: 1, + verdict: "READY", + state: "CONSUMED", + fingerprint: fingerprintBrief(admissionBrief), + }), }), - })) + ) }), ) @@ -923,21 +1092,23 @@ config: yield* Effect.promise(() => Bun.write(specPath, "config:\n nodes: [\n")) const info = yield* WorkflowTool const workflow = yield* info.init() - const exit = yield* workflow.execute( - { - action: "start", - spec_path: specPath, - }, - { - sessionID: SessionID.make("ses_workflow_parent"), - messageID: MessageID.ascending(), - agent: "build", - abort: new AbortController().signal, - messages: [], - metadata: () => Effect.void, - ask: () => Effect.void, - } satisfies Tool.Context, - ).pipe(Effect.exit) + const exit = yield* workflow + .execute( + { + action: "start", + spec_path: specPath, + }, + { + sessionID: SessionID.make("ses_workflow_parent"), + messageID: MessageID.ascending(), + agent: "build", + abort: new AbortController().signal, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + } satisfies Tool.Context, + ) + .pipe(Effect.exit) expect(Exit.isFailure(exit)).toBe(true) if (Exit.isFailure(exit)) { @@ -999,10 +1170,7 @@ config: ) yield* Effect.promise(() => fs.mkdir(path.join(missingModelDirectory, ".opencode"), { recursive: true })) yield* Effect.promise(() => - Bun.write( - path.join(missingModelDirectory, ".opencode", "dag.jsonc"), - '{ "model": {} }\n', - ) + Bun.write(path.join(missingModelDirectory, ".opencode", "dag.jsonc"), '{ "model": {} }\n'), ) yield* Effect.promise(() => Bun.write( @@ -1010,16 +1178,18 @@ config: JSON.stringify({ config: { name: "missing-model", - nodes: [{ - id: "worker", - name: "Worker", - worker_type: "build", - depends_on: [], - prompt_template: { inline: "work" }, - }], + nodes: [ + { + id: "worker", + name: "Worker", + worker_type: "build", + depends_on: [], + prompt_template: { inline: "work" }, + }, + ], }, }), - ) + ), ) const info = yield* WorkflowTool @@ -1080,12 +1250,14 @@ config: const created = published.find((event) => event.type === DagEvent.WorkflowCreated.type)?.data as { config?: string } - expect(JSON.parse(created.config ?? "{}").admission).toEqual(expect.objectContaining({ - verdict: "WAIVED", - state: "CONSUMED", - waiver_reason: "Preview release only", - acknowledged_risks: ["Production rollout is unresolved"], - })) + expect(JSON.parse(created.config ?? "{}").admission).toEqual( + expect.objectContaining({ + verdict: "WAIVED", + state: "CONSUMED", + waiver_reason: "Preview release only", + acknowledged_risks: ["Production rollout is unresolved"], + }), + ) }), ) @@ -1135,21 +1307,23 @@ config: for (const item of cases) { published.length = 0 const specPath = yield* writeWorkflowSpec(`blocked-${item.name}`, item.value) - const exit = yield* workflow.execute( - { - action: "start", - spec_path: specPath, - }, - { - sessionID: SessionID.make("ses_workflow_parent"), - messageID: MessageID.ascending(), - agent: "build", - abort: new AbortController().signal, - messages: [], - metadata: () => Effect.void, - ask: () => Effect.void, - } satisfies Tool.Context, - ).pipe(Effect.exit) + const exit = yield* workflow + .execute( + { + action: "start", + spec_path: specPath, + }, + { + sessionID: SessionID.make("ses_workflow_parent"), + messageID: MessageID.ascending(), + agent: "build", + abort: new AbortController().signal, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + } satisfies Tool.Context, + ) + .pipe(Effect.exit) expect(Exit.isFailure(exit)).toBe(true) expect(published).toHaveLength(0) @@ -1471,8 +1645,7 @@ describe("workflow tool saved workflows", () => { }), ) - const savedSpec = (name: string) => - `title: ${name} title\nconfig:\n name: ${name}\n nodes: []\n` + const savedSpec = (name: string) => `title: ${name} title\nconfig:\n name: ${name}\n nodes: []\n` const contextWith = (asked: unknown[]) => ({ @@ -1511,10 +1684,7 @@ describe("workflow tool saved workflows", () => { const workflow = yield* info.init() const asked: unknown[] = [] - const result = yield* workflow.execute( - { action: "read", spec_path: "saved-readable" }, - contextWith(asked), - ) + const result = yield* workflow.execute({ action: "read", spec_path: "saved-readable" }, contextWith(asked)) expect(result.title).toBe("Workflow spec: saved-readable") expect(JSON.parse(result.output)).toMatchObject({ @@ -1524,7 +1694,7 @@ describe("workflow tool saved workflows", () => { blocks: [{ id: "map", kind: "explore" }], }, }) - expect(asked).toHaveLength(0) + expect(asked).toEqual([expect.objectContaining({ permission: "workflow", patterns: ["read"] })]) expect(published).toHaveLength(0) }), ), @@ -1548,7 +1718,7 @@ describe("workflow tool saved workflows", () => { expect(result.output).toContain('state="running"') expect(result.title).toBe("Workflow started: saved-project") - expect(asked).toHaveLength(0) + expect(asked).toEqual([expect.objectContaining({ permission: "workflow", patterns: ["start"] })]) }), ), ) @@ -1569,7 +1739,7 @@ describe("workflow tool saved workflows", () => { expect(result.title).toBe("Workflow started: saved-global") // The library's two scopes are curated config, so a resolved name never // asks for external-directory permission. - expect(asked).toHaveLength(0) + expect(asked).toEqual([expect.objectContaining({ permission: "workflow", patterns: ["start"] })]) }), ), ) diff --git a/packages/opencode/test/tool/task.test.ts b/packages/opencode/test/tool/task.test.ts index b4e5e23c3c..11257220bd 100644 --- a/packages/opencode/test/tool/task.test.ts +++ b/packages/opencode/test/tool/task.test.ts @@ -133,6 +133,49 @@ function reply(input: SessionPrompt.PromptInput, text: string): SessionV1.WithPa } describe("tool.task", () => { + it.instance("rejects direct task execution from a child before permission or session creation", () => + Effect.gen(function* () { + const sessions = yield* Session.Service + const { chat, assistant } = yield* seed() + const child = yield* sessions.create({ title: "DAG child", parentID: chat.id }) + const state = { asks: 0, prompts: 0 } + const tool = yield* TaskTool + const def = yield* tool.init() + const exit = yield* def + .execute( + { + description: "escape orchestration", + prompt: "start an unmanaged grandchild", + subagent_type: "general", + }, + { + sessionID: child.id, + messageID: assistant.id, + agent: "plan", + abort: new AbortController().signal, + extra: { + promptOps: stubOps({ + onPrompt: () => { + state.prompts += 1 + }, + }), + }, + messages: [], + metadata: () => Effect.void, + ask: () => + Effect.sync(() => { + state.asks += 1 + }), + }, + ) + .pipe(Effect.exit) + + expect(Exit.isFailure(exit)).toBe(true) + expect(state).toEqual({ asks: 0, prompts: 0 }) + expect((yield* sessions.list()).filter((info) => info.parentID === child.id)).toHaveLength(0) + }), + ) + it.instance( "description sorts subagents by name and is stable across calls", () => @@ -218,7 +261,8 @@ describe("tool.task", () => { const build = yield* agent.get("build") const registry = yield* ToolRegistry.Service const description = - (yield* registry.tools({ ...ref, agent: build })).find((tool) => tool.id === WorkflowTool.id)?.description ?? "" + (yield* registry.tools({ ...ref, agent: build })).find((tool) => tool.id === WorkflowTool.id)?.description ?? + "" const taskDescription = (yield* registry.tools({ ...ref, agent: build })).find((tool) => tool.id === TaskTool.id)?.description ?? "" @@ -267,7 +311,8 @@ describe("tool.task", () => { const build = yield* agent.get("build") const registry = yield* ToolRegistry.Service const description = - (yield* registry.tools({ ...ref, agent: build })).find((tool) => tool.id === WorkflowTool.id)?.description ?? "" + (yield* registry.tools({ ...ref, agent: build })).find((tool) => tool.id === WorkflowTool.id)?.description ?? + "" expect(description).toContain("- alpha: Alpha agent [model: configured]") expect(description).not.toContain("SECRET SYSTEM PROMPT") From 3384d8c912814db54432cb0125c9cc72ac2ab8c3 Mon Sep 17 00:00:00 2001 From: lex Date: Mon, 10 Aug 2026 14:14:49 +0800 Subject: [PATCH 2/4] fix(dag): bind review evidence contracts --- .../src/plugin/command/workflow-blocks.md | 4 +- packages/opencode/src/dag/blocks.ts | 17 +++-- packages/opencode/src/tool/workflow.ts | 68 +++++++++++-------- packages/opencode/test/dag/blocks.test.ts | 23 +++++++ .../opencode/test/dag/workflow-tool.test.ts | 54 +++++++++------ 5 files changed, 111 insertions(+), 55 deletions(-) diff --git a/packages/core/src/plugin/command/workflow-blocks.md b/packages/core/src/plugin/command/workflow-blocks.md index 5c0773b22f..cb6ea49c3f 100644 --- a/packages/core/src/plugin/command/workflow-blocks.md +++ b/packages/core/src/plugin/command/workflow-blocks.md @@ -53,7 +53,9 @@ or existing durable node IDs during **extend** and replan. - `explore`: read-only repository mapping and evidence collection. - `plan`: implementation-ready decomposition, seams, checks, and risks. - `prototype`: the smallest throwaway experiment that resolves a runnable - uncertainty; it does not silently become production code. + uncertainty; it does not silently become production code. It still publishes + its changed-file list and fingerprint so later verification or review cannot + bind to stale experiment evidence. - `debug`: expands to reproduce/evidence followed by root-cause diagnosis. - `coding`: bounded production implementation plus focused tests and checks. - `verify`: deterministic acceptance checks with explicit PASS/FAIL evidence. diff --git a/packages/opencode/src/dag/blocks.ts b/packages/opencode/src/dag/blocks.ts index 10424ab28a..b88415b434 100644 --- a/packages/opencode/src/dag/blocks.ts +++ b/packages/opencode/src/dag/blocks.ts @@ -102,7 +102,7 @@ const BLOCK_CONTRACTS: Record = { "Inspect the target read-only. Map relevant modules, constraints, existing conventions, and evidence with file references. Do not implement.", plan: "Produce an implementation-ready plan from repository evidence and dependency outputs. Name seams, work packages, acceptance checks, and unresolved risks. Do not implement.", prototype: - "Build only the smallest throwaway experiment needed to answer the stated uncertainty. Separate observations from production recommendations and do not integrate it unless explicitly instructed.", + "Build only the smallest throwaway experiment needed to answer the stated uncertainty. Separate observations from production recommendations and do not integrate it unless explicitly instructed. Submit its changed-file list and a stable fingerprint so downstream verification and review can bind to the exact experiment.", debug: "Diagnose the smallest falsifiable root-cause hypothesis from reproduced evidence. Distinguish cause from symptom and identify the narrowest safe repair plus a regression check.", coding: @@ -263,8 +263,11 @@ function compileBlock(objective: string, block: WorkflowBlock, blocks: WorkflowB required, reportToParent: block.report_to_parent ?? block.kind === "synthesize", condition, - outputSchema: - block.kind === "coding" ? IMPLEMENTATION_SCHEMA : block.kind === "verify" ? VERIFICATION_SCHEMA : undefined, + outputSchema: WRITER_KINDS.has(block.kind) + ? IMPLEMENTATION_SCHEMA + : block.kind === "verify" + ? VERIFICATION_SCHEMA + : undefined, }), ] } @@ -367,8 +370,14 @@ function serializeWorkspaceWriters(blocks: WorkflowBlock[]) { const previous = previousWriter.get(block.id) if (!previous || dependsTransitively(blocks, block.id, previous)) return block return new WorkflowBlock({ - ...block, + id: block.id, + kind: block.kind, depends_on: [...(block.depends_on ?? []), previous], + instruction: block.instruction, + skills: block.skills, + worker_type: block.worker_type, + required: block.required, + report_to_parent: block.report_to_parent, }) }) topologicalBlocks(serialized) diff --git a/packages/opencode/src/tool/workflow.ts b/packages/opencode/src/tool/workflow.ts index 1553747f50..31cc5b15d2 100644 --- a/packages/opencode/src/tool/workflow.ts +++ b/packages/opencode/src/tool/workflow.ts @@ -22,15 +22,17 @@ const MAX_WORKFLOW_SPEC_BYTES = 1_000_000 const DEFAULT_RESULT_PAGE_CHARS = 8_000 const MAX_RESULT_PAGE_CHARS = 12_000 -const ResultCursor = Schema.fromJsonString( - Schema.Struct({ - version: Schema.Literal(1), - workflow_id: Schema.String, - node_id: Schema.String, - offset: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)), - }), -) -const decodeResultCursor = Schema.decodeUnknownOption(ResultCursor) +class ResultCursor extends Schema.Class("WorkflowResultCursor")({ + version: Schema.Literal(1), + workflow_id: Dag.ID, + node_id: Dag.NodeID, + offset: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)), +}) {} + +const ResultCursorJSON = Schema.fromJsonString(ResultCursor) +const ResultCursorToken = Schema.String.pipe(Schema.brand("WorkflowResultCursorToken")) +type ResultCursorToken = typeof ResultCursorToken.Type +const decodeResultCursor = Schema.decodeUnknownOption(ResultCursorJSON) // ============================================================================ // Action schemas remain the single validation authority for file and inline input. @@ -167,11 +169,11 @@ export const Parameters = Schema.Struct({ project_id: Schema.optional(Schema.String).annotate({ description: "(start) Optional Project ID; must match the parent session project", }), - workflow_id: Schema.optional(Schema.String).annotate({ + workflow_id: Schema.optional(Dag.ID).annotate({ description: "(extend/control/status/result) Target workflow ID", }), - node_id: Schema.optional(Schema.String).annotate({ description: "(result) Target durable node ID" }), - cursor: Schema.optional(Schema.String).annotate({ + node_id: Schema.optional(Dag.NodeID).annotate({ description: "(result) Target durable node ID" }), + cursor: Schema.optional(ResultCursorToken).annotate({ description: "(result) Opaque continuation cursor returned by the previous page", }), limit: Schema.optional(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: MAX_RESULT_PAGE_CHARS }))).annotate({ @@ -187,10 +189,10 @@ export const Parameters = Schema.Struct({ // ============================================================================ type Metadata = { - workflowId?: string - nodeId?: string + workflowId?: Dag.ID + nodeId?: Dag.NodeID truncated?: boolean - nextCursor?: string + nextCursor?: ResultCursorToken added?: string[] cancel?: string[] restart?: string[] @@ -210,7 +212,7 @@ export const WorkflowTool = Tool.define< const question = yield* Question.Service const requireOwnedWorkflow = Effect.fn("WorkflowTool.requireOwnedWorkflow")(function* ( - workflowID: string, + workflowID: Dag.ID, sessionID: string, ) { const workflow = yield* dag.store.getWorkflow(workflowID).pipe(Effect.orDie) @@ -373,12 +375,14 @@ export const WorkflowTool = Tool.define< } const cursor = params.cursor ? decodeResultCursor(Buffer.from(params.cursor, "base64url").toString()) - : Option.some({ - version: 1 as const, - workflow_id: params.workflow_id, - node_id: params.node_id, - offset: 0, - }) + : Option.some( + new ResultCursor({ + version: 1 as const, + workflow_id: params.workflow_id, + node_id: params.node_id, + offset: 0, + }), + ) if ( Option.isNone(cursor) || cursor.value.workflow_id !== params.workflow_id || @@ -399,14 +403,18 @@ export const WorkflowTool = Tool.define< const pageEnd = resultPageEnd(content, cursor.value.offset, params.limit ?? DEFAULT_RESULT_PAGE_CHARS) const truncated = pageEnd < content.length const nextCursor = truncated - ? Buffer.from( - JSON.stringify({ - version: 1, - workflow_id: params.workflow_id, - node_id: params.node_id, - offset: pageEnd, - }), - ).toString("base64url") + ? ResultCursorToken.make( + Buffer.from( + JSON.stringify( + new ResultCursor({ + version: 1, + workflow_id: params.workflow_id, + node_id: params.node_id, + offset: pageEnd, + }), + ), + ).toString("base64url"), + ) : null return { title: `Workflow result: ${node.name}`, diff --git a/packages/opencode/test/dag/blocks.test.ts b/packages/opencode/test/dag/blocks.test.ts index 0af491e914..773b72605e 100644 --- a/packages/opencode/test/dag/blocks.test.ts +++ b/packages/opencode/test/dag/blocks.test.ts @@ -131,6 +131,29 @@ describe("workflow blocks", () => { ).toThrow("requires exactly one verification ancestor") }) + it("publishes evidence when a prototype is the latest verified writer", () => { + const nodes = DagBlocks.compileWorkflowBlocks({ + objective: "Validate a prototype before deciding whether to promote it", + blocks: [ + { id: "implementation", kind: "coding" }, + { id: "experiment", kind: "prototype", depends_on: ["implementation"] }, + { id: "verification", kind: "verify", depends_on: ["experiment"] }, + { id: "decision", kind: "review", depends_on: ["verification"] }, + ], + }) + + expect(nodes.find((node) => node.id === "experiment")?.output_schema).toEqual( + expect.objectContaining({ required: expect.arrayContaining(["changed_files", "fingerprint"]) }), + ) + expect(nodes.find((node) => node.id === "decision")).toMatchObject({ + review: { implementation_node_id: "experiment", verification_node_id: "verification" }, + input_mapping: { + implementation_changed_files: "experiment.output.changed_files", + implementation_fingerprint: "experiment.output.fingerprint", + }, + }) + }) + it("serializes unordered workspace writers while leaving read-only lanes parallel", () => { const nodes = DagBlocks.compileWorkflowBlocks({ objective: "Build two packages from independent evidence", diff --git a/packages/opencode/test/dag/workflow-tool.test.ts b/packages/opencode/test/dag/workflow-tool.test.ts index c6416ceb09..66821b7bd2 100644 --- a/packages/opencode/test/dag/workflow-tool.test.ts +++ b/packages/opencode/test/dag/workflow-tool.test.ts @@ -411,11 +411,11 @@ describe("workflow tool schema (negative tests)", () => { const decode = Schema.decodeUnknownSync(Parameters) expect(() => decode({ action: "start", spec_path: ".opencode/workflows/test.yaml" })).not.toThrow() expect(() => - decode({ action: "extend", workflow_id: "wf-1", spec_path: ".opencode/workflows/extend.yaml" }), + decode({ action: "extend", workflow_id: "dag_wf_1", spec_path: ".opencode/workflows/extend.yaml" }), ).not.toThrow() - expect(() => decode({ action: "control", workflow_id: "wf-1", operation: "pause" })).not.toThrow() - expect(() => decode({ action: "status", workflow_id: "wf-1" })).not.toThrow() - expect(() => decode({ action: "result", workflow_id: "wf-1", node_id: "node-1", limit: 600 })).not.toThrow() + expect(() => decode({ action: "control", workflow_id: "dag_wf_1", operation: "pause" })).not.toThrow() + expect(() => decode({ action: "status", workflow_id: "dag_wf_1" })).not.toThrow() + expect(() => decode({ action: "result", workflow_id: "dag_wf_1", node_id: "node-1", limit: 600 })).not.toThrow() // list browses the saved-spec library and needs no workflow_id. expect(() => decode({ action: "list" })).not.toThrow() expect(() => decode({ action: "read", spec_path: "project-change-route" })).not.toThrow() @@ -439,6 +439,14 @@ describe("workflow tool schema (negative tests)", () => { expect(() => decode({ action: "delete" })).toThrow() }) + it("workflow IDs use the durable DAG identity schema", () => { + const decode = Schema.decodeUnknownSync(Parameters) + expect(() => decode({ action: "status", workflow_id: "workflow-1" })).toThrow() + expect(decode({ action: "status", workflow_id: "dag_workflow_1" })).toMatchObject({ + workflow_id: "dag_workflow_1", + }) + }) + it("no node_complete action exists", () => { const decode = Schema.decodeUnknownSync(Parameters) expect(() => decode({ action: "node_complete" })).toThrow() @@ -453,14 +461,14 @@ describe("workflow tool schema (negative tests)", () => { it("control operation accepts pause/resume/cancel/replan/step/complete", () => { const decode = Schema.decodeUnknownSync(Parameters) for (const op of ["pause", "resume", "cancel", "replan", "step", "complete"]) { - expect(() => decode({ action: "control", workflow_id: "wf-1", operation: op })).not.toThrow() + expect(() => decode({ action: "control", workflow_id: "dag_wf_1", operation: op })).not.toThrow() } }) it("control operation rejects unknown operations", () => { const decode = Schema.decodeUnknownSync(Parameters) - expect(() => decode({ action: "control", workflow_id: "wf-1", operation: "delete" })).toThrow() - expect(() => decode({ action: "control", workflow_id: "wf-1", operation: "start" })).toThrow() + expect(() => decode({ action: "control", workflow_id: "dag_wf_1", operation: "delete" })).toThrow() + expect(() => decode({ action: "control", workflow_id: "dag_wf_1", operation: "start" })).toThrow() }) it("keeps workflow graph and admission fields inside spec", () => { @@ -534,7 +542,7 @@ describe("workflow tool execution", () => { const result = yield* workflow.execute( { action: "status", - workflow_id: "dag_status", + workflow_id: Dag.ID.make("dag_status"), }, { sessionID: SessionID.make("ses_workflow_parent"), @@ -640,7 +648,7 @@ describe("workflow tool execution", () => { const workflow = yield* info.init() const exit = yield* workflow .execute( - { action: "control", workflow_id: "dag_status", operation: "pause" }, + { action: "control", workflow_id: Dag.ID.make("dag_status"), operation: "pause" }, { ...toolContext(), ask: (request) => { @@ -679,7 +687,7 @@ describe("workflow tool execution", () => { } satisfies Tool.Context const statusExit = yield* Effect.exit( - workflow.execute({ action: "status", workflow_id: "dag_status" }, foreignContext), + workflow.execute({ action: "status", workflow_id: Dag.ID.make("dag_status") }, foreignContext), ) const resultExit = yield* Effect.exit( workflow.execute( @@ -692,10 +700,16 @@ describe("workflow tool execution", () => { ), ) const extendExit = yield* Effect.exit( - workflow.execute({ action: "extend", workflow_id: "dag_defaults", spec: { nodes: [] } }, foreignContext), + workflow.execute( + { action: "extend", workflow_id: Dag.ID.make("dag_defaults"), spec: { nodes: [] } }, + foreignContext, + ), ) const controlExit = yield* Effect.exit( - workflow.execute({ action: "control", workflow_id: "dag_status", operation: "pause" }, foreignContext), + workflow.execute( + { action: "control", workflow_id: Dag.ID.make("dag_status"), operation: "pause" }, + foreignContext, + ), ) expect({ @@ -723,11 +737,11 @@ describe("workflow tool execution", () => { const info = yield* WorkflowTool const workflow = yield* info.init() const controls = [ - { workflowID: "dag_status", operation: "pause" }, - { workflowID: "dag_paused", operation: "resume" }, - { workflowID: "dag_status", operation: "cancel" }, - { workflowID: "dag_status", operation: "complete" }, - { workflowID: "dag_step", operation: "step" }, + { workflowID: Dag.ID.make("dag_status"), operation: "pause" }, + { workflowID: Dag.ID.make("dag_paused"), operation: "resume" }, + { workflowID: Dag.ID.make("dag_status"), operation: "cancel" }, + { workflowID: Dag.ID.make("dag_status"), operation: "complete" }, + { workflowID: Dag.ID.make("dag_step"), operation: "step" }, ] as const const routed = yield* Effect.forEach(controls, (control) => Effect.gen(function* () { @@ -948,7 +962,7 @@ describe("workflow tool execution", () => { const result = yield* workflow.execute( { action: "status", - workflow_id: "dag_deep_status", + workflow_id: Dag.ID.make("dag_deep_status"), }, { sessionID: SessionID.make("ses_workflow_parent"), @@ -1472,7 +1486,7 @@ config: yield* workflow.execute( { action: "extend", - workflow_id: "dag_defaults", + workflow_id: Dag.ID.make("dag_defaults"), spec_path: specPath, }, { @@ -1531,7 +1545,7 @@ config: yield* workflow.execute( { action: "control", - workflow_id: "dag_defaults", + workflow_id: Dag.ID.make("dag_defaults"), operation: "replan", spec_path: specPath, }, From d43534de56dabee70d84e12a15b9fc819a8ab18e Mon Sep 17 00:00:00 2001 From: lex Date: Mon, 10 Aug 2026 14:21:32 +0800 Subject: [PATCH 3/4] chore(dag): narrow acceptance fix diff --- packages/core/src/plugin/command/workflow.md | 42 +- packages/opencode/src/dag/runtime/loop.ts | 367 ++++------ packages/opencode/src/session/tools.ts | 302 ++++---- .../test/dag/dag-wake-integration.test.ts | 677 +++++++++--------- .../opencode/test/dag/workflow-tool.test.ts | 245 ++++--- 5 files changed, 748 insertions(+), 885 deletions(-) diff --git a/packages/core/src/plugin/command/workflow.md b/packages/core/src/plugin/command/workflow.md index d23c2a88fc..bc1e296b4e 100644 --- a/packages/core/src/plugin/command/workflow.md +++ b/packages/core/src/plugin/command/workflow.md @@ -408,12 +408,12 @@ appears as `failed` with error_reason `cancelled via replan` and NO `error_class` — deliberate action, no triage needed. Triage on the class before acting: -| error_class | What it means | Correct response | -| --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `timeout` | The node exceeded `timeout_ms`; the runtime cancelled its child session at the deadline. Environmental — the task is NOT wrong. | Replace and rerun ONLY that node with a larger `worker_config.timeout_ms`. Check its `child_session_id` for partial artifacts before rerunning. | -| `exec_failed` | Runtime/session-level failure. Gate on `error_reason`: (a) unknown/wrong model, auth, rate-limit, connection, template-resolution or condition-expression errors → config/prompt errors; (b) recovery reasons ("no child session on recovery", "child session failed (recovered)") → crash ownership loss; (c) workflow-collateral reasons (`required node(s) failed: ...`, `unresolved review outcome(s): ...`, `orchestrator_unresponsive`) → the node itself was fine; it was failed because the workflow failed. | (a) Fix the config first (`dag.jsonc` tier, provider credentials, model id, template/input mapping), then replace and rerun ONLY that node. (b) Inspect the child session's artifacts, then replace and rerun. (c) Do not rerun these collateral nodes. The wake surfaces no workflow-level reason — triage from the Failed-nodes block: `required node(s) failed: ` names the culprit nodes directly (repair them); `orchestrator_unresponsive` carries NO attribution (see the recipe below). | -| `verdict_fail` | Two shapes. Ran-but-broke-contract: missing `submit_result`, schema rejection, review fingerprint mismatch. Never-ran: pre-spawn contract failures (unresolved template placeholders, review input contract). | Ran-but-broke-contract → rerun the node with the contract stated explicitly; keep the topology. Never-ran → fix the template, input_mapping, or dependency wiring first, then rerun; prompt emphasis alone does not fix broken interpolation. | -| (cascade — see below) | Dependents of a failed node. No dedicated class. | Repair the ROOT node first, then restore the dependent subtree. | +| error_class | What it means | Correct response | +|---|---|---| +| `timeout` | The node exceeded `timeout_ms`; the runtime cancelled its child session at the deadline. Environmental — the task is NOT wrong. | Replace and rerun ONLY that node with a larger `worker_config.timeout_ms`. Check its `child_session_id` for partial artifacts before rerunning. | +| `exec_failed` | Runtime/session-level failure. Gate on `error_reason`: (a) unknown/wrong model, auth, rate-limit, connection, template-resolution or condition-expression errors → config/prompt errors; (b) recovery reasons ("no child session on recovery", "child session failed (recovered)") → crash ownership loss; (c) workflow-collateral reasons (`required node(s) failed: ...`, `unresolved review outcome(s): ...`, `orchestrator_unresponsive`) → the node itself was fine; it was failed because the workflow failed. | (a) Fix the config first (`dag.jsonc` tier, provider credentials, model id, template/input mapping), then replace and rerun ONLY that node. (b) Inspect the child session's artifacts, then replace and rerun. (c) Do not rerun these collateral nodes. The wake surfaces no workflow-level reason — triage from the Failed-nodes block: `required node(s) failed: ` names the culprit nodes directly (repair them); `orchestrator_unresponsive` carries NO attribution (see the recipe below). | +| `verdict_fail` | Two shapes. Ran-but-broke-contract: missing `submit_result`, schema rejection, review fingerprint mismatch. Never-ran: pre-spawn contract failures (unresolved template placeholders, review input contract). | Ran-but-broke-contract → rerun the node with the contract stated explicitly; keep the topology. Never-ran → fix the template, input_mapping, or dependency wiring first, then rerun; prompt emphasis alone does not fix broken interpolation. | +| (cascade — see below) | Dependents of a failed node. No dedicated class. | Repair the ROOT node first, then restore the dependent subtree. | Cascade detection has two shapes: @@ -582,21 +582,21 @@ omitted content from its preview. ### Node Fields -| Field | Required | Description | -| ------------------ | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | -| `id` | yes | Unique node identifier, used in `depends_on` | -| `name` | yes | Human-readable name | -| `worker_type` | yes | Agent type (`explore`, `build`, `general`, `plan`, or custom) | -| `depends_on` | yes | Array of node IDs this node waits for (`[]` for root) | -| `required` | no | If true and this node fails, the workflow terminalizes as failed. Default: false | -| `prompt_template` | yes | `{ id: "..." }` or `{ inline: "...", input: {...} }` | -| `condition` | no | Expression evaluated before spawn; node is skipped if false | -| `input_mapping` | no | Map upstream node outputs into template variables | -| `report_to_parent` | no | If true, the parent agent is woken when this node completes or fails. The workflow's terminal status always wakes the parent regardless of this flag | -| `worker_config` | no | `{ timeout_ms }` — bounds node execution (defaults to 10 minutes if omitted) | -| `output_schema` | no | JSON Schema; when declared, the child agent must call `submit_result` to submit structured output — failure to submit results in node failure | -| `restart` | no | (replan only) Re-spawn this running node with new prompt | -| `cancel` | no | (replan only) Cancel this node | +| Field | Required | Description | +|-------|----------|-------------| +| `id` | yes | Unique node identifier, used in `depends_on` | +| `name` | yes | Human-readable name | +| `worker_type` | yes | Agent type (`explore`, `build`, `general`, `plan`, or custom) | +| `depends_on` | yes | Array of node IDs this node waits for (`[]` for root) | +| `required` | no | If true and this node fails, the workflow terminalizes as failed. Default: false | +| `prompt_template` | yes | `{ id: "..." }` or `{ inline: "...", input: {...} }` | +| `condition` | no | Expression evaluated before spawn; node is skipped if false | +| `input_mapping` | no | Map upstream node outputs into template variables | +| `report_to_parent` | no | If true, the parent agent is woken when this node completes or fails. The workflow's terminal status always wakes the parent regardless of this flag | +| `worker_config` | no | `{ timeout_ms }` — bounds node execution (defaults to 10 minutes if omitted) | +| `output_schema` | no | JSON Schema; when declared, the child agent must call `submit_result` to submit structured output — failure to submit results in node failure | +| `restart` | no | (replan only) Re-spawn this running node with new prompt | +| `cancel` | no | (replan only) Cancel this node | ### What NOT to expect diff --git a/packages/opencode/src/dag/runtime/loop.ts b/packages/opencode/src/dag/runtime/loop.ts index 664beedb02..c5bcdfd294 100644 --- a/packages/opencode/src/dag/runtime/loop.ts +++ b/packages/opencode/src/dag/runtime/loop.ts @@ -124,9 +124,7 @@ export const layer = Layer.effect( const promptParts: { type: "text"; text: string }[] = [] let resolvedMapping: Record = {} - const inputMapping = - nodeConfig?.input_mapping ?? - Object.fromEntries(node.dependsOn.map((dependency) => [dependency, dependency])) + const inputMapping = nodeConfig?.input_mapping ?? Object.fromEntries(node.dependsOn.map((dependency) => [dependency, dependency])) if (Object.keys(inputMapping).length > 0) { resolvedMapping = resolveInputMapping(inputMapping, (depId) => { const depNode = nodesSnapshot.find((n) => n.id === depId) @@ -153,19 +151,17 @@ export const layer = Layer.effect( if (nodeConfig) { const reviewInput = validateReviewExecutionInput(nodeConfig, resolvedMapping) if (!reviewInput.valid) { - yield* dag - .nodeFailed( - dagID, - nodeID, - `Review input contract failed: ${reviewInput.errors.join("; ")}`, - "verdict_fail", - ) - .pipe(Effect.ignore) + yield* dag.nodeFailed( + dagID, + nodeID, + `Review input contract failed: ${reviewInput.errors.join("; ")}`, + "verdict_fail", + ).pipe(Effect.ignore) continue } } - const resolved = yield* nodeConfig?.prompt_template + const resolved = yield* (nodeConfig?.prompt_template ? renderTemplate(nodeConfig.prompt_template, ctx.directory, resolvedMapping).pipe( Effect.tap((result) => result.text.trim() === "" @@ -175,9 +171,7 @@ export const layer = Layer.effect( Effect.map((result) => ({ ok: true as const, ...result })), Effect.catch((err: unknown) => Effect.gen(function* () { - yield* dag - .nodeFailed(dagID, nodeID, `Template resolution failed: ${String(err)}`, "exec_failed") - .pipe(Effect.ignore) + yield* dag.nodeFailed(dagID, nodeID, `Template resolution failed: ${String(err)}`, "exec_failed").pipe(Effect.ignore) return { ok: false as const, text: "", unresolvedPlaceholders: [] } }), ), @@ -186,18 +180,16 @@ export const layer = Layer.effect( ok: true as const, text: node.name, unresolvedPlaceholders: [], - }) + })) if (!resolved.ok) continue if (resolved.unresolvedPlaceholders.length > 0) { - yield* dag - .nodeFailed( - dagID, - nodeID, - `Unresolved template placeholders: ${resolved.unresolvedPlaceholders.join(", ")}`, - "verdict_fail", - ) - .pipe(Effect.ignore) + yield* dag.nodeFailed( + dagID, + nodeID, + `Unresolved template placeholders: ${resolved.unresolvedPlaceholders.join(", ")}`, + "verdict_fail", + ).pipe(Effect.ignore) continue } @@ -251,8 +243,7 @@ export const layer = Layer.effect( : undefined, fallbackModel: DagConfig.tierModel(dagConfig, { required: node.required, workerType: node.workerType }), variant: dagConfig.thinking_depth, - maxTimeoutExtensions: - entry.config?.max_timeout_extensions ?? Dag.DEFAULT_WORKFLOW_CONFIG.maxTimeoutExtensions, + maxTimeoutExtensions: entry.config?.max_timeout_extensions ?? Dag.DEFAULT_WORKFLOW_CONFIG.maxTimeoutExtensions, }).pipe( Effect.tap((result) => Effect.sync(() => { @@ -264,7 +255,9 @@ export const layer = Layer.effect( Effect.provideService(Agent.Service, agentSvc), Effect.provideService(Session.Service, sessionSvc), Effect.provideService(SessionPrompt.Service, promptSvc), - Effect.catchCause((cause) => dag.nodeFailed(dagID, nodeID, Cause.pretty(cause), "exec_failed")), + Effect.catchCause((cause) => + dag.nodeFailed(dagID, nodeID, Cause.pretty(cause), "exec_failed"), + ), Effect.ignore, ) } @@ -293,7 +286,9 @@ export const layer = Layer.effect( yield* dag.fail(dagID, `required node(s) failed: ${entry.runtime.getRequiredFailures().join(", ")}`) return } - const unresolvedReviews = entry.config ? unresolvedReviewOutcomes(entry.config, nodes) : [] + const unresolvedReviews = entry.config + ? unresolvedReviewOutcomes(entry.config, nodes) + : [] if (unresolvedReviews.length > 0) { yield* dag.fail(dagID, `unresolved review outcome(s): ${unresolvedReviews.join(", ")}`) return @@ -316,10 +311,8 @@ export const layer = Layer.effect( // absorbs the error channel) and would kill the forked runForEach fiber — // leaving that event type permanently unhandled for the rest of the // process. catchCause absorbs failures AND defects at the boundary. - const guarded = - (event: string) => - (self: Effect.Effect) => - self.pipe(Effect.catchCause((cause) => Effect.logWarning("DagLoop handler failed", { event, cause }))) + const guarded = (event: string) => (self: Effect.Effect) => + self.pipe(Effect.catchCause((cause) => Effect.logWarning("DagLoop handler failed", { event, cause }))) const recoverWorkflow = Effect.fn("DagLoop.recoverWorkflow")(function* (wf: DagStore.WorkflowRow) { // Cross-instance guard: DagLoop is per-directory InstanceState but the @@ -347,7 +340,9 @@ export const layer = Layer.effect( checkSessionStatus, (sid) => promptSvc.cancel(sid as never), config, - ).pipe(Effect.provideService(Dag.Service, dag)) + ).pipe( + Effect.provideService(Dag.Service, dag), + ) // P2-2 recovery-pause: reconciliation invented failures (ownership // lost / no child session / deadline enforced offline) without any // durable proof of the child's outcome. Letting spawnReady cascade @@ -394,15 +389,7 @@ export const layer = Layer.effect( const isStepping = wf.status === "stepping" if (isPaused) runtime.setPaused(true) if (isStepping) runtime.setStepMode(true) - const entry: WorkflowEntry = { - runtime, - semaphore, - evalLock: Semaphore.makeUnsafe(1), - parentSessionID: wf.sessionId, - config, - fibers: new Map(), - watchers: new Map(), - } + const entry: WorkflowEntry = { runtime, semaphore, evalLock: Semaphore.makeUnsafe(1), parentSessionID: wf.sessionId, config, fibers: new Map(), watchers: new Map() } runtimes.set(dagID, entry) // Reconciliation settles every persisted running attempt before the // runtime is rebuilt. Recovery never adopts or restarts provider work; @@ -494,15 +481,7 @@ export const layer = Layer.effect( const maxConcurrency = Math.max(1, config?.max_concurrency ?? Dag.DEFAULT_WORKFLOW_CONFIG.maxConcurrency) const runtime = new WorkflowRuntime(toSchedulingNodes(nodes), maxConcurrency) const semaphore = Semaphore.makeUnsafe(maxConcurrency) - const entry: WorkflowEntry = { - runtime, - semaphore, - evalLock: Semaphore.makeUnsafe(1), - parentSessionID: wf.sessionId, - config, - fibers: new Map(), - watchers: new Map(), - } + const entry: WorkflowEntry = { runtime, semaphore, evalLock: Semaphore.makeUnsafe(1), parentSessionID: wf.sessionId, config, fibers: new Map(), watchers: new Map() } runtimes.set(dagID, entry) yield* entry.evalLock.withPermits(1)( Effect.gen(function* () { @@ -519,10 +498,9 @@ export const layer = Layer.effect( // A completed node is an output-producing success; a skipped node is a // terminal no-output state that must stay distinguishable so pure-skip // descendants cascade instead of running (D13). - const settle = - def === DagEvent.NodeSkipped - ? (entry: WorkflowEntry, nodeID: string) => entry.runtime.markSkipped(nodeID) - : (entry: WorkflowEntry, nodeID: string) => entry.runtime.markSatisfied(nodeID) + const settle = def === DagEvent.NodeSkipped + ? (entry: WorkflowEntry, nodeID: string) => entry.runtime.markSkipped(nodeID) + : (entry: WorkflowEntry, nodeID: string) => entry.runtime.markSatisfied(nodeID) yield* events.subscribe(def).pipe( Stream.filter((e) => runtimes.has(e.data.dagID as string)), Stream.runForEach((evt) => @@ -569,12 +547,7 @@ export const layer = Layer.effect( entry.watchers.delete(nodeID) } if (!confirmed) { - yield* Effect.logDebug("DagLoop dropped stale node terminal event", { - dagID, - nodeID, - expected, - dbStatus: node?.status ?? "missing", - }) + yield* Effect.logDebug("DagLoop dropped stale node terminal event", { dagID, nodeID, expected, dbStatus: node?.status ?? "missing" }) } const workflow = yield* store.getWorkflow(dagID) entry.runtime.setPaused(workflow?.status === "paused") @@ -633,53 +606,49 @@ export const layer = Layer.effect( yield* events.subscribe(DagEvent.NodeFailed).pipe( Stream.filter((e) => runtimes.has(e.data.dagID as string)), - Stream.runForEach((evt) => - Effect.gen(function* () { - const dagID = evt.data.dagID as string - const entry = runtimes.get(dagID) - if (!entry) return - yield* entry.evalLock.withPermits(1)( - Effect.gen(function* () { - const nid = evt.data.nodeID as string - // Generation arbitration via DB status: each projector runs - // INSIDE the durable publish transaction (core/dag/projector.ts), - // so by the time this handler consumes the event the row - // already reflects it. If the row is no longer "failed", a - // later NodeRestarted/replan reset the node — this event - // belongs to a previous generation and must not touch the - // new one (including the fiber map, which may already hold - // the new attempt's fiber). No generation field needed. - const node = yield* store.getNode(dagID, nid) - // #3: only markUnsatisfied if the runtime still tracks this - // node as non-terminal. A stale NodeFailed event (e.g. from - // a replan-ceiling check after the node already completed) - // would incorrectly flip a satisfied node to unsatisfied. - if (node?.status === "failed" && entry.runtime.isActive(nid)) { - const fiber = entry.fibers.get(nid) - const watcher = entry.watchers.get(nid) - entry.fibers.delete(nid) - entry.watchers.delete(nid) - yield* abortChild(nid, node.childSessionId ?? null).pipe(Effect.ignore) - if (fiber) yield* Fiber.interrupt(fiber).pipe(Effect.ignore) - if (watcher) yield* Fiber.interrupt(watcher).pipe(Effect.ignore) - entry.runtime.markUnsatisfied(nid) - if (!entry.runtime.isStepMode()) yield* spawnReady(dagID) - } - if (node?.status !== "failed") { - yield* Effect.logDebug("DagLoop dropped stale NodeFailed", { - dagID, - nodeID: nid, - dbStatus: node?.status ?? "missing", - }) - } - // In stepMode, checkCompletion (which can trigger autonomous - // fail/complete) still runs, but spawnReady is skipped — - // stepping must NOT auto-advance after a node fails. - yield* checkCompletion(dagID) - }), - ) - yield* tryDeliverWake(entry.parentSessionID).pipe(Effect.ignore, Effect.forkScoped) - }).pipe(guarded("NodeFailed")), + Stream.runForEach((evt) => + Effect.gen(function* () { + const dagID = evt.data.dagID as string + const entry = runtimes.get(dagID) + if (!entry) return + yield* entry.evalLock.withPermits(1)( + Effect.gen(function* () { + const nid = evt.data.nodeID as string + // Generation arbitration via DB status: each projector runs + // INSIDE the durable publish transaction (core/dag/projector.ts), + // so by the time this handler consumes the event the row + // already reflects it. If the row is no longer "failed", a + // later NodeRestarted/replan reset the node — this event + // belongs to a previous generation and must not touch the + // new one (including the fiber map, which may already hold + // the new attempt's fiber). No generation field needed. + const node = yield* store.getNode(dagID, nid) + // #3: only markUnsatisfied if the runtime still tracks this + // node as non-terminal. A stale NodeFailed event (e.g. from + // a replan-ceiling check after the node already completed) + // would incorrectly flip a satisfied node to unsatisfied. + if (node?.status === "failed" && entry.runtime.isActive(nid)) { + const fiber = entry.fibers.get(nid) + const watcher = entry.watchers.get(nid) + entry.fibers.delete(nid) + entry.watchers.delete(nid) + yield* abortChild(nid, node.childSessionId ?? null).pipe(Effect.ignore) + if (fiber) yield* Fiber.interrupt(fiber).pipe(Effect.ignore) + if (watcher) yield* Fiber.interrupt(watcher).pipe(Effect.ignore) + entry.runtime.markUnsatisfied(nid) + if (!entry.runtime.isStepMode()) yield* spawnReady(dagID) + } + if (node?.status !== "failed") { + yield* Effect.logDebug("DagLoop dropped stale NodeFailed", { dagID, nodeID: nid, dbStatus: node?.status ?? "missing" }) + } + // In stepMode, checkCompletion (which can trigger autonomous + // fail/complete) still runs, but spawnReady is skipped — + // stepping must NOT auto-advance after a node fails. + yield* checkCompletion(dagID) + }), + ) + yield* tryDeliverWake(entry.parentSessionID).pipe(Effect.ignore, Effect.forkScoped) + }).pipe(guarded("NodeFailed")), ), Effect.forkScoped({ startImmediately: true }), ) @@ -807,8 +776,7 @@ export const layer = Layer.effect( if (node.status !== "running") continue const frag = newConfig?.nodes.find((candidate) => candidate.id === node.id) if (!frag) continue - const oldTimeoutMs = oldConfig?.nodes.find((candidate) => candidate.id === node.id)?.worker_config - ?.timeout_ms + const oldTimeoutMs = oldConfig?.nodes.find((candidate) => candidate.id === node.id)?.worker_config?.timeout_ms const fragTimeoutMs = frag.worker_config?.timeout_ms // §3.7: re-time only when the replan carries a NEW // timeout_ms. The persisted config behind WorkflowReplanned @@ -846,10 +814,9 @@ export const layer = Layer.effect( // by the deadlineElapsed case on the public path and is a // no-op there (cons-F1). if ( - (!node.escalationPending && node.deadlineMs != null && node.deadlineMs > now) || - (node.escalationPending && !node.wakeReported) - ) - continue + (!node.escalationPending && node.deadlineMs != null && node.deadlineMs > now) + || (node.escalationPending && !node.wakeReported) + ) continue // N1: write the new deadline FIRST. nodeExtendTimeout // acquires the workflow lock and can fail or block; if the // write never lands, the old watcher must keep supervising @@ -867,18 +834,15 @@ export const layer = Layer.effect( // a structural check, whereas Cause.interruptors collects // only DEFINED fiber IDs and ignores interrupt reasons // carrying none — those would be swallowed as errors here. - const written = yield* dag - .nodeExtendTimeout(dagID, node.id, now + fragTimeoutMs) - .pipe( - Effect.catchCause((cause) => - Cause.hasInterrupts(cause) - ? Effect.failCause(cause) - : Effect.logWarning( - "DagLoop replan re-time failed; keeping the old watcher and continuing the batch", - { dagID, nodeID: node.id, cause }, - ).pipe(Effect.as(-1)), - ), - ) + const written = yield* dag.nodeExtendTimeout(dagID, node.id, now + fragTimeoutMs).pipe( + Effect.catchCause((cause) => + Cause.hasInterrupts(cause) + ? Effect.failCause(cause) + : Effect.logWarning("DagLoop replan re-time failed; keeping the old watcher and continuing the batch", { dagID, nodeID: node.id, cause }).pipe( + Effect.as(-1), + ), + ), + ) // Negative verdict: -1 (write failure, mapped above) OR -2 // (Q2 delivery-gate rejection — the node is STILL RUNNING but // its escalation wake was undelivered, raced in by the watchdog @@ -900,10 +864,7 @@ export const layer = Layer.effect( const deadWatcher = entry.watchers.get(node.id) if (deadWatcher) yield* Fiber.interrupt(deadWatcher).pipe(Effect.ignore) entry.watchers.delete(node.id) - yield* Effect.logWarning("DagLoop replan re-time skipped — node no longer running", { - dagID, - nodeID: node.id, - }) + yield* Effect.logWarning("DagLoop replan re-time skipped — node no longer running", { dagID, nodeID: node.id }) continue } // Write committed: install the re-armed watcher BEFORE @@ -921,8 +882,7 @@ export const layer = Layer.effect( dagID, nodeID: node.id, timeoutMs: fragTimeoutMs, - maxTimeoutExtensions: - newConfig?.max_timeout_extensions ?? Dag.DEFAULT_WORKFLOW_CONFIG.maxTimeoutExtensions, + maxTimeoutExtensions: newConfig?.max_timeout_extensions ?? Dag.DEFAULT_WORKFLOW_CONFIG.maxTimeoutExtensions, }).pipe( Effect.provideService(Dag.Service, dag), Effect.provideService(SessionPrompt.Service, promptSvc), @@ -931,11 +891,7 @@ export const layer = Layer.effect( const oldWatcher = entry.watchers.get(node.id) entry.watchers.set(node.id, newWatcher) if (oldWatcher) yield* Fiber.interrupt(oldWatcher).pipe(Effect.ignore) - yield* Effect.logInfo("DagLoop extended node deadline via replan", { - dagID, - nodeID: node.id, - newDeadlineMs: now + fragTimeoutMs, - }) + yield* Effect.logInfo("DagLoop extended node deadline via replan", { dagID, nodeID: node.id, newDeadlineMs: now + fragTimeoutMs }) } // Replan resets restarted nodes to pending. Old fibers of nodes // that are no longer running/queued must be interrupted here: @@ -1003,9 +959,11 @@ export const layer = Layer.effect( // already idle (P1-2 fix). const readWakeBatch = Effect.fn("DagLoop.readWakeBatch")(function* (sessionID: string) { - const snapshot = yield* store - .getWakeSnapshot(sessionID) - .pipe(Effect.catch(() => Effect.succeed({ nodes: [], workflows: [] } satisfies DagStore.WakeSnapshot))) + const snapshot = yield* store.getWakeSnapshot(sessionID).pipe( + Effect.catch(() => + Effect.succeed({ nodes: [], workflows: [] } satisfies DagStore.WakeSnapshot), + ), + ) const terminalWorkflows = snapshot.workflows.filter( (workflow) => !workflow.wakeReported && isWorkflowTerminalStatus(workflow.status as never), ) @@ -1021,18 +979,13 @@ export const layer = Layer.effect( // override the delivery boundary for the rest of the attempt. const escalatedWorkflowIDs = new Set( snapshot.nodes - .filter( - (node) => - node.escalationPending || (node.timeoutExtensions > 0 && isNodeTerminalStatus(node.status as never)), - ) + .filter((node) => node.escalationPending || (node.timeoutExtensions > 0 && isNodeTerminalStatus(node.status as never))) .map((node) => node.workflowId), ) - const workflowIDs = [ - ...new Set([ - ...snapshot.nodes.map((node) => node.workflowId), - ...terminalWorkflows.map((workflow) => workflow.id), - ]), - ] + const workflowIDs = [...new Set([ + ...snapshot.nodes.map((node) => node.workflowId), + ...terminalWorkflows.map((workflow) => workflow.id), + ])] const workflowsByID = new Map(snapshot.workflows.map((workflow) => [workflow.id, workflow])) const workflows = workflowIDs.map((workflowID) => workflowsByID.get(workflowID)) const boundaryWorkflows = workflows.filter((workflow): workflow is DagStore.WorkflowRow => { @@ -1068,7 +1021,9 @@ export const layer = Layer.effect( boundaryWorkflows .filter((workflow) => { const entry = runtimes.get(workflow.id) - return workflow.status === "running" && !entry?.runtime.isPaused() && !entry?.runtime.isStepMode() + return workflow.status === "running" + && !entry?.runtime.isPaused() + && !entry?.runtime.isStepMode() }) .map((workflow) => workflow.id), ), @@ -1121,13 +1076,13 @@ export const layer = Layer.effect( yield* entry.evalLock.withPermits(1)( Effect.gen(function* () { const shouldFail = - !entry.runtime.isPaused() && - !entry.runtime.isStepMode() && + !entry.runtime.isPaused() + && !entry.runtime.isStepMode() // Suppress the net only when current-process execution // ownership proves that a running node is making progress. - !entry.runtime.hasRunningMatching((id) => entry.fibers.has(id)) && - entry.runtime.getReadyNodes().length === 0 && - !entry.runtime.isComplete() + && !entry.runtime.hasRunningMatching((id) => entry.fibers.has(id)) + && entry.runtime.getReadyNodes().length === 0 + && !entry.runtime.isComplete() if (shouldFail) yield* dag.fail(dagID, "orchestrator_unresponsive").pipe(Effect.ignore) }), ) @@ -1139,9 +1094,7 @@ export const layer = Layer.effect( if ((yield* statusSvc.get(SessionID.make(sessionID))).type !== "idle") return // Preemption guard (task 3.3): abort if fresher user message exists - const msgs = yield* sessionSvc - .messages({ sessionID: SessionID.make(sessionID), limit: 20 }) - .pipe(Effect.catch(() => Effect.succeed([]))) + const msgs = yield* sessionSvc.messages({ sessionID: SessionID.make(sessionID), limit: 20 }).pipe(Effect.catch(() => Effect.succeed([]))) let lastUserAt = -1 let lastAsstAt = -1 for (const m of msgs) { @@ -1156,18 +1109,10 @@ export const layer = Layer.effect( for (const workflow of batch.workflows) { if (workflow.status !== "failed") continue const failedNodes = yield* store.getNodes(workflow.id).pipe( - Effect.map((nodes) => - nodes.filter( - (node): node is DagStore.NodeRow & { errorClass: string } => - node.status === "failed" && node.errorClass !== null, - ), - ), + Effect.map((nodes) => nodes.filter((node): node is DagStore.NodeRow & { errorClass: string } => node.status === "failed" && node.errorClass !== null)), Effect.catchCause((cause) => Effect.gen(function* () { - yield* Effect.logWarning("wake digest failed to read failed nodes", { - workflowId: workflow.id, - cause, - }) + yield* Effect.logWarning("wake digest failed to read failed nodes", { workflowId: workflow.id, cause }) return [] as (DagStore.NodeRow & { errorClass: string })[] }), ), @@ -1175,9 +1120,7 @@ export const layer = Layer.effect( if (failedNodes.length > 0) { failuresByWorkflow.set( workflow.id, - failedNodes.map((node) => - `- "${node.name}" (${node.errorClass}): ${node.errorReason ?? "unknown error"}`.slice(0, 300), - ), + failedNodes.map((node) => `- "${node.name}" (${node.errorClass}): ${node.errorReason ?? "unknown error"}`.slice(0, 300)), ) } } @@ -1212,9 +1155,7 @@ export const layer = Layer.effect( const summary = [ ...summaries, ...(plan.actionableDagIDs.size > 0 - ? [ - 'You MUST act on these workflows in this turn (workflow tool: extend / control replan / complete / cancel). If this turn ends with a workflow stalled and no action taken, it will be failed with reason "orchestrator_unresponsive".', - ] + ? ['You MUST act on these workflows in this turn (workflow tool: extend / control replan / complete / cancel). If this turn ends with a workflow stalled and no action taken, it will be failed with reason "orchestrator_unresponsive".'] : []), ].join("\n\n") @@ -1225,32 +1166,28 @@ export const layer = Layer.effect( // receives the node result and can act) but NOT rendered as a user // message in the TUI chat — DAG data surfaces via the sidebar panel // and Inspector, keeping the chat conversation clean. - const didDeliver = yield* promptSvc - .promptIfIdle({ - sessionID: SessionID.make(sessionID), - parts: [{ type: "text", text: summary, synthetic: true }], - }) - .pipe( - Effect.flatMap( - Option.match({ - onNone: () => Effect.succeed(false), - onSome: () => - store.markWakeBatchReported(batch).pipe( - Effect.tap(() => - Effect.sync(() => { - plan.unresponsiveDagIDs.forEach((workflowID) => - deliveredUnresponsiveDagIDs.add(workflowID), - ) - }), - ), - Effect.as(true), - ), - }), - ), - Effect.catchCause(() => - Effect.logWarning("DAG wake delivery failed", { sessionID }).pipe(Effect.as(false)), - ), - ) + const didDeliver = yield* promptSvc.promptIfIdle({ + sessionID: SessionID.make(sessionID), + parts: [{ type: "text", text: summary, synthetic: true }], + }).pipe( + Effect.flatMap(Option.match({ + onNone: () => Effect.succeed(false), + onSome: () => + store.markWakeBatchReported(batch).pipe( + Effect.tap(() => + Effect.sync(() => { + plan.unresponsiveDagIDs.forEach((workflowID) => + deliveredUnresponsiveDagIDs.add(workflowID), + ) + }), + ), + Effect.as(true), + ), + })), + Effect.catchCause(() => + Effect.logWarning("DAG wake delivery failed", { sessionID }).pipe(Effect.as(false)), + ), + ) if (!didDeliver) return } } finally { @@ -1303,29 +1240,25 @@ export const layer = Layer.effect( // absorb them with a warning so layer construction survives, but never // silently — a swallowed failure means wake redelivery is lost until // the next process restart. - const pendingWakeSessions = yield* store - .getSessionsWithUnreportedWakes() - .pipe( - Effect.catchCause((cause) => - Effect.logWarning("DagLoop failed to list sessions with unreported wakes", { cause }).pipe( - Effect.as([] as string[]), - ), + const pendingWakeSessions = yield* store.getSessionsWithUnreportedWakes().pipe( + Effect.catchCause((cause) => + Effect.logWarning("DagLoop failed to list sessions with unreported wakes", { cause }).pipe( + Effect.as([] as string[]), ), - ) + ), + ) for (const sessionID of pendingWakeSessions) { // Cross-instance guard: wake redelivery is store-global. A session's // workflows share its project (enforced at dag.create), so the wake // snapshot's own workflow rows carry the ownership proof — only // drain sessions whose unreported workflows belong to this project. - const snapshot = yield* store - .getWakeSnapshot(sessionID) - .pipe( - Effect.catchCause((cause) => - Effect.logWarning("DagLoop failed to read wake snapshot", { sessionID, cause }).pipe( - Effect.as({ nodes: [], workflows: [] } satisfies DagStore.WakeSnapshot), - ), + const snapshot = yield* store.getWakeSnapshot(sessionID).pipe( + Effect.catchCause((cause) => + Effect.logWarning("DagLoop failed to read wake snapshot", { sessionID, cause }).pipe( + Effect.as({ nodes: [], workflows: [] } satisfies DagStore.WakeSnapshot), ), - ) + ), + ) if (!snapshot.workflows.some((wf) => wf.projectId === ctx.project.id)) continue yield* tryDeliverWake(sessionID).pipe(Effect.forkScoped) } diff --git a/packages/opencode/src/session/tools.ts b/packages/opencode/src/session/tools.ts index bb2670fd8a..c4f2370607 100644 --- a/packages/opencode/src/session/tools.ts +++ b/packages/opencode/src/session/tools.ts @@ -121,170 +121,133 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { SessionContext.run(context(args, options).sessionID, () => Effect.gen(function* () { const ctx = context(args, options) - yield* plugin.trigger( - "tool.execute.before", - { tool: item.id, sessionID: ctx.sessionID, callID: ctx.callID }, - { args }, - ) - // SettingsHook PreToolUse - let preContexts: string[] = [] - if (settingsHook) { - const preResult = yield* settingsHook - .trigger( - { event: "PreToolUse", toolName: item.id, toolInput: toRecord(args), toolUseID: ctx.callID }, - { sessionID: ctx.sessionID, transcriptPath: "" }, - ) - .pipe( - Effect.catch(() => Effect.succeed({ additionalContexts: [], systemMessages: [] })), - ) - yield* SettingsHook.landSystemMessages(preResult, { sessionID: ctx.sessionID }) - const decision = applyPreHookDecision(toRecord(args), preResult) - if (decision.deniedReason) { - return { - output: `[Tool denied by hook] ${decision.deniedReason}`, - attachments: [], - metadata: { hookDenied: true }, - } as any - } - if (decision.stopReason) { - return { - output: `[Hook stopped] ${decision.stopReason}`, - attachments: [], - metadata: { hookStopped: true }, - } as any - } - // permissionDecision:"ask" — invoke the confirmation dialog. We call - // permission.ask directly (NOT the orDie-piped ctx.ask) and classify the - // outcome: typed rejections become a denied result, while interrupts - // (session abort mid-dialog) and defects propagate instead of being - // masked as a denial. - if (preResult.permissionDecision === "ask") { - const askReason = preResult.permissionDecisionReason - const verdict = yield* permission - .ask({ - permission: item.id, - sessionID: ctx.sessionID, - patterns: [item.id], - always: [], - metadata: { hookAsk: true, ...(askReason ? { reason: askReason } : {}) }, - tool: { messageID: input.processor.message.id, callID: options.toolCallId }, - ruleset: [], - }) - .pipe(Effect.exit) - const outcome = classifyPermissionAsk(verdict) - if (outcome !== "approved" && outcome !== "denied") - return yield* Effect.failCause(outcome.propagate as never) - if (outcome === "denied") { - const reason = askReason ?? "Denied by user in hook confirmation" - return { - output: `[Tool denied by hook] ${reason}`, - attachments: [], - metadata: { hookDenied: true }, - } as any - } + yield* plugin.trigger( + "tool.execute.before", + { tool: item.id, sessionID: ctx.sessionID, callID: ctx.callID }, + { args }, + ) + // SettingsHook PreToolUse + let preContexts: string[] = [] + if (settingsHook) { + const preResult = yield* settingsHook + .trigger( + { event: "PreToolUse", toolName: item.id, toolInput: toRecord(args), toolUseID: ctx.callID }, + { sessionID: ctx.sessionID, transcriptPath: "" }, + ) + .pipe(Effect.catch(() => Effect.succeed({ additionalContexts: [], systemMessages: [] }))) + yield* SettingsHook.landSystemMessages(preResult, { sessionID: ctx.sessionID }) + const decision = applyPreHookDecision(toRecord(args), preResult) + if (decision.deniedReason) { + return { output: `[Tool denied by hook] ${decision.deniedReason}`, attachments: [], metadata: { hookDenied: true } } as any + } + if (decision.stopReason) { + return { output: `[Hook stopped] ${decision.stopReason}`, attachments: [], metadata: { hookStopped: true } } as any + } + // permissionDecision:"ask" — invoke the confirmation dialog. We call + // permission.ask directly (NOT the orDie-piped ctx.ask) and classify the + // outcome: typed rejections become a denied result, while interrupts + // (session abort mid-dialog) and defects propagate instead of being + // masked as a denial. + if (preResult.permissionDecision === "ask") { + const askReason = preResult.permissionDecisionReason + const verdict = yield* permission + .ask({ + permission: item.id, + sessionID: ctx.sessionID, + patterns: [item.id], + always: [], + metadata: { hookAsk: true, ...(askReason ? { reason: askReason } : {}) }, + tool: { messageID: input.processor.message.id, callID: options.toolCallId }, + ruleset: [], + }) + .pipe(Effect.exit) + const outcome = classifyPermissionAsk(verdict) + if (outcome !== "approved" && outcome !== "denied") return yield* Effect.failCause(outcome.propagate as never) + if (outcome === "denied") { + const reason = askReason ?? "Denied by user in hook confirmation" + return { output: `[Tool denied by hook] ${reason}`, attachments: [], metadata: { hookDenied: true } } as any } - preContexts = preResult.additionalContexts ?? [] - // effectiveArgs reflects any PreToolUse updatedInput rewrite (shallow merge). - args = decision.effectiveArgs } - const result = yield* Effect.suspend(() => { - const cleanup = setActiveElicitationSession(ctx.sessionID) - return item.execute(args, ctx).pipe(Effect.ensuring(Effect.sync(cleanup))) - }) - const output = { - ...result, - attachments: result.attachments?.map((attachment) => ({ - ...attachment, - id: PartID.ascending(), - sessionID: ctx.sessionID, - messageID: input.processor.message.id, - })), + preContexts = preResult.additionalContexts ?? [] + // effectiveArgs reflects any PreToolUse updatedInput rewrite (shallow merge). + args = decision.effectiveArgs + } + const result = yield* Effect.suspend(() => { + const cleanup = setActiveElicitationSession(ctx.sessionID) + return item.execute(args, ctx).pipe(Effect.ensuring(Effect.sync(cleanup))) + }) + const output = { + ...result, + attachments: result.attachments?.map((attachment) => ({ + ...attachment, + id: PartID.ascending(), + sessionID: ctx.sessionID, + messageID: input.processor.message.id, + })), + } + // PreToolUse additionalContexts: prepend so the model sees any hook-injected + // gate/reminder before the tool result (mirrors PostToolUse surfacing below). + if (preContexts.length) { + output.output = `${preContexts.join("\n\n")}\n\n${output.output ?? ""}` + } + yield* plugin.trigger( + "tool.execute.after", + { tool: item.id, sessionID: ctx.sessionID, callID: ctx.callID, args }, + output, + ) + // SettingsHook PostToolUse + if (settingsHook) { + const postResult = yield* settingsHook + .trigger( + { event: "PostToolUse", toolName: item.id, toolInput: toRecord(args), toolResponse: output.output, toolUseID: ctx.callID } as any, + { sessionID: ctx.sessionID, transcriptPath: "" }, + ) + .pipe(Effect.catch(() => Effect.succeed({ additionalContexts: [] as string[] } as any))) + yield* SettingsHook.landSystemMessages(postResult as TriggerResult, { sessionID: ctx.sessionID }) + // Inject additionalContext into tool output so model sees it + if ((postResult as any).additionalContexts?.length) { + output.output += "\n\n" + (postResult as any).additionalContexts.join("\n") } - // PreToolUse additionalContexts: prepend so the model sees any hook-injected - // gate/reminder before the tool result (mirrors PostToolUse surfacing below). - if (preContexts.length) { - output.output = `${preContexts.join("\n\n")}\n\n${output.output ?? ""}` + // PostToolUse preventContinuation: tool already executed, so annotate + // the output rather than skipping. Soft signal, mirrors CC semantics. + if ((postResult as any).preventContinuation) { + const stopReason = (postResult as any).stopReason ?? "Hook requested stop" + output.output += `\n\n[Hook stopped] ${stopReason}` } - yield* plugin.trigger( - "tool.execute.after", - { tool: item.id, sessionID: ctx.sessionID, callID: ctx.callID, args }, - output, - ) - // SettingsHook PostToolUse + } + // SettingsHook FileChanged for file-modifying tools + if (settingsHook && FILE_CHANGING_TOOLS.has(item.id)) { + const fileResult = yield* settingsHook + .trigger( + { event: "FileChanged", path: (toRecord(args))["file_path"] ?? (toRecord(args))["path"], changeType: item.id } as any, + { sessionID: ctx.sessionID, transcriptPath: "" }, + ) + .pipe(Effect.catch(() => Effect.succeed({ additionalContexts: [], systemMessages: [] } as TriggerResult))) + yield* SettingsHook.landSystemMessages(fileResult, { sessionID: ctx.sessionID }) + } + if (options.abortSignal?.aborted) { + yield* input.processor.completeToolCall(options.toolCallId, output) + } + return output + }).pipe( + Effect.catch((error: unknown) => + Effect.gen(function* () { + // SettingsHook PostToolUseFailure if (settingsHook) { - const postResult = yield* settingsHook + const failResult = yield* settingsHook .trigger( - { - event: "PostToolUse", - toolName: item.id, - toolInput: toRecord(args), - toolResponse: output.output, - toolUseID: ctx.callID, - } as any, - { sessionID: ctx.sessionID, transcriptPath: "" }, - ) - .pipe(Effect.catch(() => Effect.succeed({ additionalContexts: [] as string[] } as any))) - yield* SettingsHook.landSystemMessages(postResult as TriggerResult, { sessionID: ctx.sessionID }) - // Inject additionalContext into tool output so model sees it - if ((postResult as any).additionalContexts?.length) { - output.output += "\n\n" + (postResult as any).additionalContexts.join("\n") - } - // PostToolUse preventContinuation: tool already executed, so annotate - // the output rather than skipping. Soft signal, mirrors CC semantics. - if ((postResult as any).preventContinuation) { - const stopReason = (postResult as any).stopReason ?? "Hook requested stop" - output.output += `\n\n[Hook stopped] ${stopReason}` - } - } - // SettingsHook FileChanged for file-modifying tools - if (settingsHook && FILE_CHANGING_TOOLS.has(item.id)) { - const fileResult = yield* settingsHook - .trigger( - { - event: "FileChanged", - path: toRecord(args)["file_path"] ?? toRecord(args)["path"], - changeType: item.id, - } as any, - { sessionID: ctx.sessionID, transcriptPath: "" }, - ) - .pipe( - Effect.catch(() => Effect.succeed({ additionalContexts: [], systemMessages: [] } as TriggerResult)), + { event: "PostToolUseFailure", toolName: item.id, toolInput: toRecord(args), error: String(error), toolUseID: options.toolCallId } as any, + { sessionID: input.session.id, transcriptPath: "" }, ) - yield* SettingsHook.landSystemMessages(fileResult, { sessionID: ctx.sessionID }) + .pipe(Effect.catch(() => Effect.succeed({ additionalContexts: [], systemMessages: [] } as TriggerResult))) + yield* SettingsHook.landSystemMessages(failResult, { sessionID: input.session.id }) } - if (options.abortSignal?.aborted) { - yield* input.processor.completeToolCall(options.toolCallId, output) - } - return output - }).pipe( - Effect.catch((error: unknown) => - Effect.gen(function* () { - // SettingsHook PostToolUseFailure - if (settingsHook) { - const failResult = yield* settingsHook - .trigger( - { - event: "PostToolUseFailure", - toolName: item.id, - toolInput: toRecord(args), - error: String(error), - toolUseID: options.toolCallId, - } as any, - { sessionID: input.session.id, transcriptPath: "" }, - ) - .pipe( - Effect.catch(() => - Effect.succeed({ additionalContexts: [], systemMessages: [] } as TriggerResult), - ), - ) - yield* SettingsHook.landSystemMessages(failResult, { sessionID: input.session.id }) - } - return yield* Effect.fail(error) - }), - ), + return yield* Effect.fail(error) + }), ), ), - ) + ), + ) }, }) } @@ -565,12 +528,12 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { { event: "PreToolUse", toolName: key, toolInput: toRecord(args), toolUseID: opts.toolCallId }, { sessionID: ctx.sessionID, transcriptPath: "" }, ) - .pipe(Effect.catch(() => Effect.succeed({ additionalContexts: [], systemMessages: [] }))) - yield* SettingsHook.landSystemMessages(preResult, { sessionID: ctx.sessionID }) - const decision = applyPreHookDecision(toRecord(args), preResult) - if (decision.deniedReason) { - return { content: [{ type: "text", text: `[Tool denied by hook] ${decision.deniedReason}` }] } as any - } + .pipe(Effect.catch(() => Effect.succeed({ additionalContexts: [], systemMessages: [] }))) + yield* SettingsHook.landSystemMessages(preResult, { sessionID: ctx.sessionID }) + const decision = applyPreHookDecision(toRecord(args), preResult) + if (decision.deniedReason) { + return { content: [{ type: "text", text: `[Tool denied by hook] ${decision.deniedReason}` }] } as any + } if (decision.stopReason) { return { content: [{ type: "text", text: `[Hook stopped] ${decision.stopReason}` }] } as any } @@ -590,8 +553,7 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { }) .pipe(Effect.exit) const outcome = classifyPermissionAsk(verdict) - if (outcome !== "approved" && outcome !== "denied") - return yield* Effect.failCause(outcome.propagate as never) + if (outcome !== "approved" && outcome !== "denied") return yield* Effect.failCause(outcome.propagate as never) if (outcome === "denied") { const reason = askReason ?? "Denied by user in hook confirmation" return { content: [{ type: "text", text: `[Tool denied by hook] ${reason}` }] } as any @@ -688,13 +650,7 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { if (settingsHook) { const postResult = yield* settingsHook .trigger( - { - event: "PostToolUse", - toolName: key, - toolInput: toRecord(args), - toolResponse: output.output, - toolUseID: opts.toolCallId, - } as any, + { event: "PostToolUse", toolName: key, toolInput: toRecord(args), toolResponse: output.output, toolUseID: opts.toolCallId } as any, { sessionID: ctx.sessionID, transcriptPath: "" }, ) .pipe(Effect.catch(() => Effect.succeed({ additionalContexts: [] as string[] } as any))) @@ -719,13 +675,7 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { if (settingsHook) { yield* settingsHook .trigger( - { - event: "PostToolUseFailure", - toolName: key, - toolInput: toRecord(args), - error: String(error), - toolUseID: opts.toolCallId, - } as any, + { event: "PostToolUseFailure", toolName: key, toolInput: toRecord(args), error: String(error), toolUseID: opts.toolCallId } as any, { sessionID: input.session.id, transcriptPath: "" }, ) .pipe(Effect.catch(() => Effect.succeed(undefined as any))) diff --git a/packages/opencode/test/dag/dag-wake-integration.test.ts b/packages/opencode/test/dag/dag-wake-integration.test.ts index f15be709b1..9a57d2ac50 100644 --- a/packages/opencode/test/dag/dag-wake-integration.test.ts +++ b/packages/opencode/test/dag/dag-wake-integration.test.ts @@ -39,12 +39,10 @@ interface ParentPromptGate { function takeWithin(queue: Queue.Queue, message: string) { return Queue.take(queue).pipe( Effect.timeoutOption("1 second"), - Effect.flatMap( - Option.match({ - onNone: () => Effect.fail(new Error(message)), - onSome: Effect.succeed, - }), - ), + Effect.flatMap(Option.match({ + onNone: () => Effect.fail(new Error(message)), + onSome: Effect.succeed, + })), ) } @@ -65,7 +63,7 @@ function reply(sessionID: string, text: string): SessionV1.WithParts { time: { created: Date.now() }, finish: "stop", }, - parts: text ? ([{ type: "text", text }] as never) : [], + parts: text ? [{ type: "text", text }] as never : [], } } @@ -90,7 +88,9 @@ function promptText(input: SessionPrompt.PromptInput) { function waitForCompletion(store: DagStore.Interface, dagID: string, message: string) { return pollWithTimeout( - store.getWorkflow(dagID).pipe(Effect.map((workflow) => (workflow?.status === "completed" ? true : undefined))), + store.getWorkflow(dagID).pipe( + Effect.map((workflow) => workflow?.status === "completed" ? true : undefined), + ), message, ) } @@ -105,8 +105,14 @@ function wakeLayer(input: { const bridge = EventV2Bridge.layer.pipe(Layer.provide(events)) const store = DagStore.layer.pipe(Layer.provide(database)) const status = SessionStatus.layer.pipe(Layer.provide(bridge)) - const projector = DagProjector.layer.pipe(Layer.provide(events), Layer.provide(database)) - const dag = Dag.layer.pipe(Layer.provide(bridge), Layer.provide(store)) + const projector = DagProjector.layer.pipe( + Layer.provide(events), + Layer.provide(database), + ) + const dag = Dag.layer.pipe( + Layer.provide(bridge), + Layer.provide(store), + ) const base = Layer.mergeAll(database, events, bridge, store, projector, dag, status) const childTitles = new Map() const created: string[] = [] @@ -126,7 +132,9 @@ function wakeLayer(input: { if (sessionID === "ses_parent") { const release = yield* Deferred.make<"success" | "failure">() yield* Queue.offer(input.parentPrompts, { input: value, release }) - const outcome = yield* Deferred.await(release).pipe(Effect.ensuring(Queue.offer(input.parentSettled, undefined))) + const outcome = yield* Deferred.await(release).pipe( + Effect.ensuring(Queue.offer(input.parentSettled, undefined)), + ) if (outcome === "failure") return yield* Effect.die(new Error("provider unavailable")) return reply(sessionID, "parent handled wake") } @@ -144,18 +152,17 @@ function wakeLayer(input: { promptIfIdle: (value) => deliver(value).pipe(Effect.map(Option.some)), }) const agent = Layer.mock(Agent.Service, { - get: () => - Effect.succeed({ - name: "build", - mode: "all", - permission: [], - options: {}, - description: "", - prompt: "", - model: { providerID: "test" as never, modelID: "test-model" as never }, - tools: {}, - hooks: {}, - }), + get: () => Effect.succeed({ + name: "build", + mode: "all", + permission: [], + options: {}, + description: "", + prompt: "", + model: { providerID: "test" as never, modelID: "test-model" as never }, + tools: {}, + hooks: {}, + }), }) const loop = DagLoop.layer.pipe( Layer.provide(base), @@ -176,7 +183,9 @@ function runWakeTest( readonly parentPrompts: Queue.Queue readonly parentSettled: Queue.Queue }) => Effect.Effect, - beforeInit?: (services: { readonly database: Database.Interface }) => Effect.Effect, + beforeInit?: (services: { + readonly database: Database.Interface + }) => Effect.Effect, ) { return Effect.gen(function* () { const childPrompts = yield* Queue.unbounded() @@ -188,27 +197,19 @@ function runWakeTest( const store = yield* DagStore.Service const status = yield* SessionStatus.Service const database = yield* Database.Service - yield* database.db - .insert(ProjectTable) - .values({ - id: "project-1" as never, - worktree: process.cwd() as never, - sandboxes: [], - }) - .run() - .pipe(Effect.orDie) - yield* database.db - .insert(SessionTable) - .values({ - id: "ses_parent" as never, - project_id: "project-1" as never, - slug: "parent", - directory: process.cwd() as never, - title: "Parent", - version: "test", - }) - .run() - .pipe(Effect.orDie) + yield* database.db.insert(ProjectTable).values({ + id: "project-1" as never, + worktree: process.cwd() as never, + sandboxes: [], + }).run().pipe(Effect.orDie) + yield* database.db.insert(SessionTable).values({ + id: "ses_parent" as never, + project_id: "project-1" as never, + slug: "parent", + directory: process.cwd() as never, + title: "Parent", + version: "test", + }).run().pipe(Effect.orDie) if (beforeInit) yield* beforeInit({ database }) yield* loop.init() return yield* test({ dag, loop, store, status, childPrompts, parentPrompts, parentSettled }) @@ -274,9 +275,9 @@ describe("DagLoop atomic wake integration", () => { yield* Deferred.succeed(implement.release, "Implemented") yield* pollWithTimeout( - store - .getWorkflow(dagID) - .pipe(Effect.map((workflow) => (workflow?.status === "completed" ? workflow : undefined))), + store.getWorkflow(dagID).pipe( + Effect.map((workflow) => workflow?.status === "completed" ? workflow : undefined), + ), "deep prompt workflow did not complete", ) }), @@ -316,9 +317,9 @@ describe("DagLoop atomic wake integration", () => { yield* Deferred.succeed(second.release, "done") yield* pollWithTimeout( - store - .getWorkflow(dagID) - .pipe(Effect.map((workflow) => (workflow?.status === "completed" ? workflow : undefined))), + store.getWorkflow(dagID).pipe( + Effect.map((workflow) => workflow?.status === "completed" ? workflow : undefined), + ), "queued-admission workflow did not complete", ) }), @@ -394,9 +395,9 @@ describe("DagLoop atomic wake integration", () => { yield* Deferred.succeed(review.release, "No security issues found.") yield* pollWithTimeout( - store - .getWorkflow(dagID) - .pipe(Effect.map((workflow) => (workflow?.status === "completed" ? workflow : undefined))), + store.getWorkflow(dagID).pipe( + Effect.map((workflow) => workflow?.status === "completed" ? workflow : undefined), + ), "workflow did not complete", ) }), @@ -449,9 +450,9 @@ describe("DagLoop atomic wake integration", () => { yield* Deferred.succeed(arbitrate.release, "Proceed with one review unavailable.") yield* pollWithTimeout( - store - .getWorkflow(dagID) - .pipe(Effect.map((workflow) => (workflow?.status === "completed" ? workflow : undefined))), + store.getWorkflow(dagID).pipe( + Effect.map((workflow) => workflow?.status === "completed" ? workflow : undefined), + ), "workflow did not complete", ) expect((yield* store.getNode(dagID, "review-security"))?.status).toBe("failed") @@ -569,9 +570,9 @@ describe("DagLoop atomic wake integration", () => { const checkpoint = yield* takeWithin(childPrompts, "checkpoint did not start") yield* Deferred.succeed(checkpoint.release, "REVISE") yield* pollWithTimeout( - store - .getWorkflow(dagID) - .pipe(Effect.map((workflow) => (workflow?.status === "completed" ? true : undefined))), + store.getWorkflow(dagID).pipe( + Effect.map((workflow) => workflow?.status === "completed" ? true : undefined), + ), "checkpoint workflow did not complete", ) @@ -586,9 +587,9 @@ describe("DagLoop atomic wake integration", () => { yield* Deferred.succeed(parent.release, "success") yield* Deferred.succeed(repair.release, "fixed") yield* pollWithTimeout( - store - .getWorkflow(dagID) - .pipe(Effect.map((workflow) => (workflow?.status === "completed" ? true : undefined))), + store.getWorkflow(dagID).pipe( + Effect.map((workflow) => workflow?.status === "completed" ? true : undefined), + ), "extended workflow did not complete", ) }), @@ -654,11 +655,10 @@ describe("DagLoop atomic wake integration", () => { yield* Deferred.succeed(downstream.release, "done") yield* waitForCompletion(store, dagID, "workflow did not complete") - const error = yield* dag - .extend(dagID, [node("repair", ["checkpoint"])]) - .pipe(Effect.catch((cause: Error) => Effect.succeed(cause))) - if (!(error instanceof TerminalViolationError)) - throw new Error("extend unexpectedly succeeded past a terminal checkpoint") + const error = yield* dag.extend(dagID, [node("repair", ["checkpoint"])]).pipe( + Effect.catch((cause: Error) => Effect.succeed(cause)), + ) + if (!(error instanceof TerminalViolationError)) throw new Error("extend unexpectedly succeeded past a terminal checkpoint") expect(error.message).toContain("continued past the checkpoint") }), ), @@ -680,16 +680,16 @@ describe("DagLoop atomic wake integration", () => { yield* takeWithin(childPrompts, "checkpoint did not start") yield* dag.complete(dagID) yield* pollWithTimeout( - store - .getWorkflow(dagID) - .pipe(Effect.map((workflow) => (workflow?.status === "completed" ? true : undefined))), + store.getWorkflow(dagID).pipe( + Effect.map((workflow) => workflow?.status === "completed" ? true : undefined), + ), "workflow did not early-complete", ) expect((yield* store.getNode(dagID, "later"))?.errorReason).toBe("agent_complete") - const error = yield* dag - .extend(dagID, [node("repair", ["checkpoint"])]) - .pipe(Effect.catch((cause: Error) => Effect.succeed(cause))) + const error = yield* dag.extend(dagID, [node("repair", ["checkpoint"])]).pipe( + Effect.catch((cause: Error) => Effect.succeed(cause)), + ) expect(error).toBeInstanceOf(TerminalViolationError) }), ), @@ -809,16 +809,16 @@ describe("DagLoop atomic wake integration", () => { const leaf = yield* takeWithin(childPrompts, "leaf did not start") yield* Deferred.succeed(leaf.release, "done") yield* pollWithTimeout( - store - .getWorkflow(dagID) - .pipe(Effect.map((workflow) => (workflow?.status === "completed" ? true : undefined))), + store.getWorkflow(dagID).pipe( + Effect.map((workflow) => workflow?.status === "completed" ? true : undefined), + ), "non-reporting workflow did not complete", ) expect((yield* store.getNode(dagID, "leaf"))?.wakeEligible).toBe(false) - const error = yield* dag - .extend(dagID, [node("extra", ["leaf"])]) - .pipe(Effect.catch((cause: Error) => Effect.succeed(cause))) + const error = yield* dag.extend(dagID, [node("extra", ["leaf"])]).pipe( + Effect.catch((cause: Error) => Effect.succeed(cause)), + ) expect(error).toBeInstanceOf(TerminalViolationError) }), ), @@ -832,24 +832,22 @@ describe("DagLoop atomic wake integration", () => { // (verdict_fail: Unresolved template placeholders). Acceptance-time // binding validation now rejects it before any node can spawn — the // "Added, then spawn-dead" silent window is gone. - const createError = yield* dag - .create({ - projectID: "project-1", - sessionID: "ses_parent", - title: "Unresolved aggregate input", - config: { - name: "unresolved-aggregate-input", - nodes: [ - node("node-a"), - { - ...node("summary", ["node-a"]), - input_mapping: {}, - prompt_template: { inline: "汇总结果:{{node-a}}" }, - }, - ], - }, - }) - .pipe(Effect.catch((cause: Error) => Effect.succeed(cause.message))) + const createError = yield* dag.create({ + projectID: "project-1", + sessionID: "ses_parent", + title: "Unresolved aggregate input", + config: { + name: "unresolved-aggregate-input", + nodes: [ + node("node-a"), + { + ...node("summary", ["node-a"]), + input_mapping: {}, + prompt_template: { inline: "汇总结果:{{node-a}}" }, + }, + ], + }, + }).pipe(Effect.catch((cause: Error) => Effect.succeed(cause.message))) expect(createError).toContain('unbound variable "{{node-a}}"') expect(yield* Queue.poll(childPrompts)).toEqual(Option.none()) }), @@ -891,9 +889,9 @@ describe("DagLoop atomic wake integration", () => { yield* Deferred.succeed(root.release, "A") yield* pollWithTimeout( - store - .getNode(dagID, "summary") - .pipe(Effect.map((item) => (item?.status === "failed" ? item : undefined))), + store.getNode(dagID, "summary").pipe( + Effect.map((item) => item?.status === "failed" ? item : undefined), + ), "summary node did not fail", ) const summary = yield* store.getNode(dagID, "summary") @@ -901,9 +899,7 @@ describe("DagLoop atomic wake integration", () => { expect(summary?.errorClass).toBe("verdict_fail") const parent = yield* takeWithin(parentPrompts, "workflow failure did not wake the parent") const wakeText = promptText(parent.input) - expect(wakeText).toContain( - '[DAG Workflow failed] Workflow "Unresolved aggregate input" has reached terminal status.', - ) + expect(wakeText).toContain('[DAG Workflow failed] Workflow "Unresolved aggregate input" has reached terminal status.') expect(wakeText).toContain('Failed nodes:\n- "summary" (verdict_fail):') yield* Deferred.succeed(parent.release, "success") expect(yield* Queue.poll(childPrompts)).toEqual(Option.none()) @@ -940,7 +936,10 @@ describe("DagLoop atomic wake integration", () => { const parent = yield* takeWithin(parentPrompts, "terminal workflow did not trigger a parent wake") yield* Deferred.succeed(prompts.get("root")!.release, "root result") - const downstream = yield* takeWithin(childPrompts, "downstream scheduling waited for the blocked parent wake") + const downstream = yield* takeWithin( + childPrompts, + "downstream scheduling waited for the blocked parent wake", + ) expect(downstream.title).toBe("downstream") yield* Deferred.succeed(parent.release, "success") @@ -1061,41 +1060,33 @@ describe("DagLoop atomic wake integration", () => { yield* Deferred.succeed(parent.release, "success") }), ({ database }) => - database.db - .transaction((tx) => - Effect.gen(function* () { - yield* tx - .insert(WorkflowTable) - .values({ - id: "recovered-workflow", - project_id: "project-1" as never, - session_id: "ses_parent" as never, - title: "Recovered workflow", - status: "completed", - config: "{}", - seq: 10, - wake_reported: false, - }) - .run() - yield* tx - .insert(WorkflowNodeTable) - .values({ - id: "recovered-node", - workflow_id: "recovered-workflow", - name: "recovered-node", - worker_type: "build", - status: "completed", - required: true, - depends_on: [], - output: "recovered", - wake_eligible: true, - wake_reported: false, - seq: 9, - }) - .run() - }), - ) - .pipe(Effect.orDie), + database.db.transaction((tx) => + Effect.gen(function* () { + yield* tx.insert(WorkflowTable).values({ + id: "recovered-workflow", + project_id: "project-1" as never, + session_id: "ses_parent" as never, + title: "Recovered workflow", + status: "completed", + config: "{}", + seq: 10, + wake_reported: false, + }).run() + yield* tx.insert(WorkflowNodeTable).values({ + id: "recovered-node", + workflow_id: "recovered-workflow", + name: "recovered-node", + worker_type: "build", + status: "completed", + required: true, + depends_on: [], + output: "recovered", + wake_eligible: true, + wake_reported: false, + seq: 9, + }).run() + }), + ).pipe(Effect.orDie), ), ) }) @@ -1114,9 +1105,9 @@ describe("DagLoop atomic wake integration", () => { const child = yield* takeWithin(childPrompts, "busy-parent node did not start") yield* Deferred.succeed(child.release, "held result") yield* pollWithTimeout( - store - .getWorkflow(dagID) - .pipe(Effect.map((workflow) => (workflow?.status === "completed" ? (true as const) : undefined))), + store.getWorkflow(dagID).pipe( + Effect.map((workflow) => workflow?.status === "completed" ? true as const : undefined), + ), "workflow did not complete while its parent was busy", ) @@ -1139,21 +1130,27 @@ describe("DagLoop atomic wake integration", () => { ({ store, childPrompts, parentPrompts }) => Effect.gen(function* () { const responder = yield* Effect.forever( - Queue.take(childPrompts).pipe(Effect.flatMap((prompt) => Deferred.succeed(prompt.release, "done"))), + Queue.take(childPrompts).pipe( + Effect.flatMap((prompt) => Deferred.succeed(prompt.release, "done")), + ), ).pipe(Effect.forkChild) const parent = yield* takeWithin( parentPrompts, "parent agent did not receive the durable DAG status after recovery", ) - expect((yield* store.getNode("dag_recovered_conditional", "conditional"))?.status).toBe("skipped") + expect( + (yield* store.getNode("dag_recovered_conditional", "conditional"))?.status, + ).toBe("skipped") // D13: after-conditional depends only on the skipped conditional // node, so it cascade-skips instead of running on a placeholder // input — the gate rejection blocks the whole downstream subtree. const afterConditional = yield* store.getNode("dag_recovered_conditional", "after-conditional") expect(afterConditional?.status).toBe("skipped") expect(afterConditional?.errorReason).toBe("orphan_cascade") - expect(promptText(parent.input)).toContain('Node "quality-gate" completed: REJECT') + expect(promptText(parent.input)).toContain( + 'Node "quality-gate" completed: REJECT', + ) expect(promptText(parent.input)).toContain( 'Workflow "Recovered conditional workflow" has reached terminal status', ) @@ -1161,81 +1158,73 @@ describe("DagLoop atomic wake integration", () => { yield* Fiber.interrupt(responder) }), ({ database }) => - database.db - .transaction((tx) => - Effect.gen(function* () { - yield* tx - .insert(WorkflowTable) - .values({ - id: "dag_recovered_conditional", - project_id: "project-1" as never, - session_id: "ses_parent" as never, - title: "Recovered conditional workflow", - status: "running", - config: JSON.stringify({ - name: "dag_recovered_conditional", - nodes: [ - node("quality-gate"), - { - ...node("conditional", ["quality-gate"]), - report_to_parent: false, - condition: 'quality-gate.output.verdict == "ACCEPT"', - }, - { - ...node("after-conditional", ["conditional"]), - report_to_parent: false, - }, - ], - }), - seq: 6, - wake_reported: false, - }) - .run() - yield* tx - .insert(WorkflowNodeTable) - .values([ - { - id: "quality-gate", - workflow_id: "dag_recovered_conditional", - name: "quality-gate", - worker_type: "build", - status: "completed", - required: true, - depends_on: [], - output: "REJECT", - wake_eligible: true, - wake_reported: false, - seq: 4, - }, + database.db.transaction((tx) => + Effect.gen(function* () { + yield* tx.insert(WorkflowTable).values({ + id: "dag_recovered_conditional", + project_id: "project-1" as never, + session_id: "ses_parent" as never, + title: "Recovered conditional workflow", + status: "running", + config: JSON.stringify({ + name: "dag_recovered_conditional", + nodes: [ + node("quality-gate"), { - id: "conditional", - workflow_id: "dag_recovered_conditional", - name: "conditional", - worker_type: "build", - status: "pending", - required: true, - depends_on: ["quality-gate"], - wake_eligible: false, - wake_reported: false, - seq: 2, + ...node("conditional", ["quality-gate"]), + report_to_parent: false, + condition: 'quality-gate.output.verdict == "ACCEPT"', }, { - id: "after-conditional", - workflow_id: "dag_recovered_conditional", - name: "after-conditional", - worker_type: "build", - status: "pending", - required: true, - depends_on: ["conditional"], - wake_eligible: false, - wake_reported: false, - seq: 1, + ...node("after-conditional", ["conditional"]), + report_to_parent: false, }, - ]) - .run() - }), - ) - .pipe(Effect.orDie), + ], + }), + seq: 6, + wake_reported: false, + }).run() + yield* tx.insert(WorkflowNodeTable).values([ + { + id: "quality-gate", + workflow_id: "dag_recovered_conditional", + name: "quality-gate", + worker_type: "build", + status: "completed", + required: true, + depends_on: [], + output: "REJECT", + wake_eligible: true, + wake_reported: false, + seq: 4, + }, + { + id: "conditional", + workflow_id: "dag_recovered_conditional", + name: "conditional", + worker_type: "build", + status: "pending", + required: true, + depends_on: ["quality-gate"], + wake_eligible: false, + wake_reported: false, + seq: 2, + }, + { + id: "after-conditional", + workflow_id: "dag_recovered_conditional", + name: "after-conditional", + worker_type: "build", + status: "pending", + required: true, + depends_on: ["conditional"], + wake_eligible: false, + wake_reported: false, + seq: 1, + }, + ]).run() + }), + ).pipe(Effect.orDie), ), ) }) @@ -1246,9 +1235,9 @@ describe("DagLoop atomic wake integration", () => { ({ store, parentPrompts }) => Effect.gen(function* () { const workflow = yield* pollWithTimeout( - store - .getWorkflow("dag_recovered_review_rejection") - .pipe(Effect.map((row) => (row?.status === "failed" ? row : undefined))), + store.getWorkflow("dag_recovered_review_rejection").pipe( + Effect.map((row) => row?.status === "failed" ? row : undefined), + ), "recovered workflow without an accepted review did not fail", ) expect((yield* store.getNode(workflow.id, "review-diff"))?.status).toBe("skipped") @@ -1261,134 +1250,126 @@ describe("DagLoop atomic wake integration", () => { yield* Deferred.succeed(parent.release, "success") }), ({ database }) => - database.db - .transaction((tx) => - Effect.gen(function* () { - const nodes = [ - { - ...node("implement"), - output_schema: { - type: "object", - properties: { - diff: { type: "string" }, - fingerprint: { type: "string" }, - }, - required: ["diff", "fingerprint"], + database.db.transaction((tx) => + Effect.gen(function* () { + const nodes = [ + { + ...node("implement"), + output_schema: { + type: "object", + properties: { + diff: { type: "string" }, + fingerprint: { type: "string" }, }, + required: ["diff", "fingerprint"], }, - { - ...node("verify", ["implement"]), - output_schema: { - type: "object", - properties: { verdict: { enum: ["PASS", "FAIL"] } }, - required: ["verdict"], - }, + }, + { + ...node("verify", ["implement"]), + output_schema: { + type: "object", + properties: { verdict: { enum: ["PASS", "FAIL"] } }, + required: ["verdict"], }, - { - ...node("review-diff", ["verify"]), - worker_type: "review", - review: { - phase: "diff" as const, - implementation_node_id: "implement", - verification_node_id: "verify", - }, - input_mapping: { - diff: "implement.output.diff", - implementation_fingerprint: "implement.output.fingerprint", - verification: "verify.output", - }, - condition: 'verify.output.verdict == "PASS"', - output_schema: { - type: "object", - properties: { - verdict: { enum: ["ACCEPT", "REJECT"] }, - implementation_fingerprint: { type: "string" }, - }, - required: ["verdict", "implementation_fingerprint"], - }, + }, + { + ...node("review-diff", ["verify"]), + worker_type: "review", + review: { + phase: "diff" as const, + implementation_node_id: "implement", + verification_node_id: "verify", }, - { - ...node("final-audit", ["review-diff"]), - worker_type: "audit", - input_mapping: { review: "review-diff.output" }, - condition: 'review-diff.output.verdict == "ACCEPT"', + input_mapping: { + diff: "implement.output.diff", + implementation_fingerprint: "implement.output.fingerprint", + verification: "verify.output", }, - ] - yield* tx - .insert(WorkflowTable) - .values({ - id: "dag_recovered_review_rejection", - project_id: "project-1" as never, - session_id: "ses_parent" as never, - title: "Recovered review rejection", - status: "running", - config: JSON.stringify({ - name: "dag_recovered_review_rejection", - mode: "deep", - nodes, - }), - seq: 10, - wake_reported: false, - }) - .run() - yield* tx - .insert(WorkflowNodeTable) - .values([ - { - id: "implement", - workflow_id: "dag_recovered_review_rejection", - name: "implement", - worker_type: "build", - status: "completed", - required: true, - depends_on: [], - output: { diff: "diff --git a/a b/a", fingerprint: "fp-1" }, - wake_eligible: false, - wake_reported: true, - seq: 6, - }, - { - id: "verify", - workflow_id: "dag_recovered_review_rejection", - name: "verify", - worker_type: "build", - status: "completed", - required: true, - depends_on: ["implement"], - output: { verdict: "FAIL" }, - wake_eligible: false, - wake_reported: true, - seq: 5, - }, - { - id: "review-diff", - workflow_id: "dag_recovered_review_rejection", - name: "review-diff", - worker_type: "review", - status: "pending", - required: true, - depends_on: ["verify"], - wake_eligible: false, - wake_reported: false, - seq: 4, + condition: 'verify.output.verdict == "PASS"', + output_schema: { + type: "object", + properties: { + verdict: { enum: ["ACCEPT", "REJECT"] }, + implementation_fingerprint: { type: "string" }, }, - { - id: "final-audit", - workflow_id: "dag_recovered_review_rejection", - name: "final-audit", - worker_type: "audit", - status: "pending", - required: true, - depends_on: ["review-diff"], - wake_eligible: false, - wake_reported: false, - seq: 3, - }, - ]) - .run() - }), - ) - .pipe(Effect.orDie), + required: ["verdict", "implementation_fingerprint"], + }, + }, + { + ...node("final-audit", ["review-diff"]), + worker_type: "audit", + input_mapping: { review: "review-diff.output" }, + condition: 'review-diff.output.verdict == "ACCEPT"', + }, + ] + yield* tx.insert(WorkflowTable).values({ + id: "dag_recovered_review_rejection", + project_id: "project-1" as never, + session_id: "ses_parent" as never, + title: "Recovered review rejection", + status: "running", + config: JSON.stringify({ + name: "dag_recovered_review_rejection", + mode: "deep", + nodes, + }), + seq: 10, + wake_reported: false, + }).run() + yield* tx.insert(WorkflowNodeTable).values([ + { + id: "implement", + workflow_id: "dag_recovered_review_rejection", + name: "implement", + worker_type: "build", + status: "completed", + required: true, + depends_on: [], + output: { diff: "diff --git a/a b/a", fingerprint: "fp-1" }, + wake_eligible: false, + wake_reported: true, + seq: 6, + }, + { + id: "verify", + workflow_id: "dag_recovered_review_rejection", + name: "verify", + worker_type: "build", + status: "completed", + required: true, + depends_on: ["implement"], + output: { verdict: "FAIL" }, + wake_eligible: false, + wake_reported: true, + seq: 5, + }, + { + id: "review-diff", + workflow_id: "dag_recovered_review_rejection", + name: "review-diff", + worker_type: "review", + status: "pending", + required: true, + depends_on: ["verify"], + wake_eligible: false, + wake_reported: false, + seq: 4, + }, + { + id: "final-audit", + workflow_id: "dag_recovered_review_rejection", + name: "final-audit", + worker_type: "audit", + status: "pending", + required: true, + depends_on: ["review-diff"], + wake_eligible: false, + wake_reported: false, + seq: 3, + }, + ]).run() + }), + ).pipe(Effect.orDie), ), ) }) @@ -1463,9 +1444,9 @@ describe("DagLoop atomic wake integration", () => { // dependency is skipped. Pre-fix, skip ≡ satisfied ran the full // chain and the audit "passed" a rejected gate. yield* pollWithTimeout( - store - .getWorkflow(dagID) - .pipe(Effect.map((workflow) => (workflow?.status === "completed" ? workflow : undefined))), + store.getWorkflow(dagID).pipe( + Effect.map((workflow) => workflow?.status === "completed" ? workflow : undefined), + ), "gated workflow did not complete after the gate rejection", ) const implement = yield* store.getNode(dagID, "implement") @@ -1511,9 +1492,9 @@ describe("DagLoop atomic wake integration", () => { expect((yield* store.getWorkflow(dagID))?.status).toBe("running") yield* Deferred.succeed(b.release, "B done") yield* pollWithTimeout( - store - .getWorkflow(dagID) - .pipe(Effect.map((workflow) => (workflow?.status === "completed" ? workflow : undefined))), + store.getWorkflow(dagID).pipe( + Effect.map((workflow) => workflow?.status === "completed" ? workflow : undefined), + ), "workflow did not complete", ) const parent = yield* takeWithin(parentPrompts, "terminal wake did not reach the parent") diff --git a/packages/opencode/test/dag/workflow-tool.test.ts b/packages/opencode/test/dag/workflow-tool.test.ts index 66821b7bd2..3dbbd948d1 100644 --- a/packages/opencode/test/dag/workflow-tool.test.ts +++ b/packages/opencode/test/dag/workflow-tool.test.ts @@ -48,14 +48,16 @@ const admissionBrief = { blocking_questions: [], } -function admissionFor(verdict: "READY" | "NOT_READY" | "WAIVED", state: State = verdict) { - const brief = - verdict === "READY" - ? admissionBrief - : { - ...admissionBrief, - blocking_questions: ["Confirm the production rollout target"], - } +function admissionFor( + verdict: "READY" | "NOT_READY" | "WAIVED", + state: State = verdict, +) { + const brief = verdict === "READY" + ? admissionBrief + : { + ...admissionBrief, + blocking_questions: ["Confirm the production rollout target"], + } return { protocol_version: 1, brief_revision: 1, @@ -307,7 +309,10 @@ const events = Layer.mock(EventV2Bridge.Service, { return { id: "event_test", type: definition.type, data } as never }), }) -const dag = Dag.layer.pipe(Layer.provide(store), Layer.provide(events)) +const dag = Dag.layer.pipe( + Layer.provide(store), + Layer.provide(events), +) const runtime = testEffect( Layer.mergeAll( Layer.mock(Agent.Service, { @@ -337,7 +342,8 @@ const runtime = testEffect( slug: "workflow-test", projectID, directory: workflowSpecDirectory, - parentID: id === SessionID.make("ses_workflow_child") ? SessionID.make("ses_workflow_parent") : undefined, + parentID: + id === SessionID.make("ses_workflow_child") ? SessionID.make("ses_workflow_parent") : undefined, title: "Workflow test", version: "test", time: { created: 0, updated: 0 }, @@ -391,7 +397,9 @@ const missingModelRuntime = testEffect( function writeWorkflowSpec(name: string, value: unknown) { const filepath = path.join(workflowSpecDirectory, `${name}.yaml`) - return Effect.promise(() => Bun.write(filepath, JSON.stringify(value, null, 2))).pipe(Effect.as(filepath)) + return Effect.promise(() => Bun.write(filepath, JSON.stringify(value, null, 2))).pipe( + Effect.as(filepath), + ) } function toolContext() { @@ -497,9 +505,10 @@ describe("workflow tool execution", () => { published.length = 0 const info = yield* WorkflowTool const workflow = yield* info.init() - const exit = yield* workflow - .execute({ action: "list" }, { ...toolContext(), sessionID: SessionID.make("ses_workflow_child") }) - .pipe(Effect.exit) + const exit = yield* workflow.execute( + { action: "list" }, + { ...toolContext(), sessionID: SessionID.make("ses_workflow_child") }, + ).pipe(Effect.exit) expect(Exit.isFailure(exit)).toBe(true) if (Exit.isFailure(exit)) expect(Cause.pretty(exit.cause)).toContain("main conversation") @@ -843,15 +852,13 @@ describe("workflow tool execution", () => { action: "extend", workflow_id: "dag_defaults", spec: { - nodes: [ - { - id: "inline-added", - name: "Inline added", - worker_type: "general", - depends_on: [], - prompt_template: { inline: "work" }, - }, - ], + nodes: [{ + id: "inline-added", + name: "Inline added", + worker_type: "general", + depends_on: [], + prompt_template: { inline: "work" }, + }], }, }), toolContext(), @@ -901,15 +908,13 @@ describe("workflow tool execution", () => { spec: { fragment: { name: "inline-replan", - nodes: [ - { - id: "inline-replanned", - name: "Inline replanned", - worker_type: "general", - depends_on: [], - prompt_template: { inline: "work" }, - }, - ], + nodes: [{ + id: "inline-replanned", + name: "Inline replanned", + worker_type: "general", + depends_on: [], + prompt_template: { inline: "work" }, + }], }, }, }), @@ -944,9 +949,10 @@ describe("workflow tool execution", () => { for (const item of cases) { published.length = 0 - const exit = yield* workflow - .execute(Schema.decodeUnknownSync(Parameters)(item.params), toolContext()) - .pipe(Effect.exit) + const exit = yield* workflow.execute( + Schema.decodeUnknownSync(Parameters)(item.params), + toolContext(), + ).pipe(Effect.exit) expect(Exit.isFailure(exit)).toBe(true) if (Exit.isFailure(exit)) expect(Cause.pretty(exit.cause)).toContain(item.message) @@ -976,20 +982,18 @@ describe("workflow tool execution", () => { ) const output = JSON.parse(result.output) - expect(output).toEqual( - expect.objectContaining({ - mode: "deep", - admission: { - verdict: "WAIVED", - state: "CONSUMED", - qa_mode: "STANDARD", - brief_revision: 1, - fingerprint: admissionFor("WAIVED").fingerprint, - waiver_reason: "Preview release only", - acknowledged_risks: ["Production rollout is unresolved"], - }, - }), - ) + expect(output).toEqual(expect.objectContaining({ + mode: "deep", + admission: { + verdict: "WAIVED", + state: "CONSUMED", + qa_mode: "STANDARD", + brief_revision: 1, + fingerprint: admissionFor("WAIVED").fingerprint, + waiver_reason: "Preview release only", + acknowledged_risks: ["Production rollout is unresolved"], + }, + })) expect(output.admission).not.toHaveProperty("qa_transcript") }), ) @@ -1025,15 +1029,13 @@ config: metadata: () => Effect.void, ask: () => Effect.void, } satisfies Tool.Context - const invalid = yield* workflow - .execute( - { - action: "start", - spec_path: "deep.yaml", - }, - context, - ) - .pipe(Effect.exit) + const invalid = yield* workflow.execute( + { + action: "start", + spec_path: "deep.yaml", + }, + context, + ).pipe(Effect.exit) expect(Exit.isFailure(invalid)).toBe(true) if (Exit.isFailure(invalid)) { @@ -1085,17 +1087,15 @@ config: const created = published.find((event) => event.type === DagEvent.WorkflowCreated.type)?.data as { config?: string } - expect(JSON.parse(created.config ?? "{}")).toEqual( - expect.objectContaining({ - mode: "deep", - admission: expect.objectContaining({ - protocol_version: 1, - verdict: "READY", - state: "CONSUMED", - fingerprint: fingerprintBrief(admissionBrief), - }), + expect(JSON.parse(created.config ?? "{}")).toEqual(expect.objectContaining({ + mode: "deep", + admission: expect.objectContaining({ + protocol_version: 1, + verdict: "READY", + state: "CONSUMED", + fingerprint: fingerprintBrief(admissionBrief), }), - ) + })) }), ) @@ -1106,23 +1106,21 @@ config: yield* Effect.promise(() => Bun.write(specPath, "config:\n nodes: [\n")) const info = yield* WorkflowTool const workflow = yield* info.init() - const exit = yield* workflow - .execute( - { - action: "start", - spec_path: specPath, - }, - { - sessionID: SessionID.make("ses_workflow_parent"), - messageID: MessageID.ascending(), - agent: "build", - abort: new AbortController().signal, - messages: [], - metadata: () => Effect.void, - ask: () => Effect.void, - } satisfies Tool.Context, - ) - .pipe(Effect.exit) + const exit = yield* workflow.execute( + { + action: "start", + spec_path: specPath, + }, + { + sessionID: SessionID.make("ses_workflow_parent"), + messageID: MessageID.ascending(), + agent: "build", + abort: new AbortController().signal, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + } satisfies Tool.Context, + ).pipe(Effect.exit) expect(Exit.isFailure(exit)).toBe(true) if (Exit.isFailure(exit)) { @@ -1184,7 +1182,10 @@ config: ) yield* Effect.promise(() => fs.mkdir(path.join(missingModelDirectory, ".opencode"), { recursive: true })) yield* Effect.promise(() => - Bun.write(path.join(missingModelDirectory, ".opencode", "dag.jsonc"), '{ "model": {} }\n'), + Bun.write( + path.join(missingModelDirectory, ".opencode", "dag.jsonc"), + '{ "model": {} }\n', + ) ) yield* Effect.promise(() => Bun.write( @@ -1192,18 +1193,16 @@ config: JSON.stringify({ config: { name: "missing-model", - nodes: [ - { - id: "worker", - name: "Worker", - worker_type: "build", - depends_on: [], - prompt_template: { inline: "work" }, - }, - ], + nodes: [{ + id: "worker", + name: "Worker", + worker_type: "build", + depends_on: [], + prompt_template: { inline: "work" }, + }], }, }), - ), + ) ) const info = yield* WorkflowTool @@ -1264,14 +1263,12 @@ config: const created = published.find((event) => event.type === DagEvent.WorkflowCreated.type)?.data as { config?: string } - expect(JSON.parse(created.config ?? "{}").admission).toEqual( - expect.objectContaining({ - verdict: "WAIVED", - state: "CONSUMED", - waiver_reason: "Preview release only", - acknowledged_risks: ["Production rollout is unresolved"], - }), - ) + expect(JSON.parse(created.config ?? "{}").admission).toEqual(expect.objectContaining({ + verdict: "WAIVED", + state: "CONSUMED", + waiver_reason: "Preview release only", + acknowledged_risks: ["Production rollout is unresolved"], + })) }), ) @@ -1321,23 +1318,21 @@ config: for (const item of cases) { published.length = 0 const specPath = yield* writeWorkflowSpec(`blocked-${item.name}`, item.value) - const exit = yield* workflow - .execute( - { - action: "start", - spec_path: specPath, - }, - { - sessionID: SessionID.make("ses_workflow_parent"), - messageID: MessageID.ascending(), - agent: "build", - abort: new AbortController().signal, - messages: [], - metadata: () => Effect.void, - ask: () => Effect.void, - } satisfies Tool.Context, - ) - .pipe(Effect.exit) + const exit = yield* workflow.execute( + { + action: "start", + spec_path: specPath, + }, + { + sessionID: SessionID.make("ses_workflow_parent"), + messageID: MessageID.ascending(), + agent: "build", + abort: new AbortController().signal, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + } satisfies Tool.Context, + ).pipe(Effect.exit) expect(Exit.isFailure(exit)).toBe(true) expect(published).toHaveLength(0) @@ -1659,7 +1654,8 @@ describe("workflow tool saved workflows", () => { }), ) - const savedSpec = (name: string) => `title: ${name} title\nconfig:\n name: ${name}\n nodes: []\n` + const savedSpec = (name: string) => + `title: ${name} title\nconfig:\n name: ${name}\n nodes: []\n` const contextWith = (asked: unknown[]) => ({ @@ -1698,7 +1694,10 @@ describe("workflow tool saved workflows", () => { const workflow = yield* info.init() const asked: unknown[] = [] - const result = yield* workflow.execute({ action: "read", spec_path: "saved-readable" }, contextWith(asked)) + const result = yield* workflow.execute( + { action: "read", spec_path: "saved-readable" }, + contextWith(asked), + ) expect(result.title).toBe("Workflow spec: saved-readable") expect(JSON.parse(result.output)).toMatchObject({ From 79f641ffa872d873fd888289453a11bd83937cc2 Mon Sep 17 00:00:00 2001 From: lex Date: Mon, 10 Aug 2026 14:37:54 +0800 Subject: [PATCH 4/4] fix(dag): prevent unreviewed standard success --- packages/opencode/src/dag/review-lifecycle.ts | 1 - packages/opencode/test/dag/dag-wake-integration.test.ts | 9 +++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/dag/review-lifecycle.ts b/packages/opencode/src/dag/review-lifecycle.ts index b6848dd818..5ee59bc9c4 100644 --- a/packages/opencode/src/dag/review-lifecycle.ts +++ b/packages/opencode/src/dag/review-lifecycle.ts @@ -150,7 +150,6 @@ export function unresolvedReviewOutcomes( config: WorkflowConfig, nodes: ReadonlyArray<{ id: string; status: string; output: unknown }>, ) { - if ((config.mode ?? "standard") !== "deep") return [] const rows = new Map(nodes.map((node) => [node.id, node])) const reviews = config.nodes.filter((node) => node.review?.phase === "diff") return reviews.flatMap((review) => { diff --git a/packages/opencode/test/dag/dag-wake-integration.test.ts b/packages/opencode/test/dag/dag-wake-integration.test.ts index 9a57d2ac50..7417be2129 100644 --- a/packages/opencode/test/dag/dag-wake-integration.test.ts +++ b/packages/opencode/test/dag/dag-wake-integration.test.ts @@ -1229,17 +1229,18 @@ describe("DagLoop atomic wake integration", () => { ) }) - it("fails a recovered deep workflow when verification skips every diff review", async () => { + it.each(["deep", "standard"] as const)("fails a recovered %s workflow when verification skips every diff review", async (mode) => { await Effect.runPromise( runWakeTest( ({ store, parentPrompts }) => Effect.gen(function* () { const workflow = yield* pollWithTimeout( store.getWorkflow("dag_recovered_review_rejection").pipe( - Effect.map((row) => row?.status === "failed" ? row : undefined), + Effect.map((row) => row && ["completed", "failed"].includes(row.status) ? row : undefined), ), - "recovered workflow without an accepted review did not fail", + "recovered workflow without an accepted review did not settle", ) + expect(workflow.status).toBe("failed") expect((yield* store.getNode(workflow.id, "review-diff"))?.status).toBe("skipped") expect((yield* store.getNode(workflow.id, "final-audit"))?.status).toBe("skipped") @@ -1310,7 +1311,7 @@ describe("DagLoop atomic wake integration", () => { status: "running", config: JSON.stringify({ name: "dag_recovered_review_rejection", - mode: "deep", + mode, nodes, }), seq: 10,