diff --git a/packages/core/src/plugin/command/dag-flow.txt b/packages/core/src/plugin/command/dag-flow.txt index 301efd746..dbc4e08d6 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 d2aaa7a6b..cb6ea49c3 100644 --- a/packages/core/src/plugin/command/workflow-blocks.md +++ b/packages/core/src/plugin/command/workflow-blocks.md @@ -53,19 +53,26 @@ 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. -- `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 +80,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 +89,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 86032b3fc..dc027c256 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 bfa260a20..bc1e296b4 100644 --- a/packages/core/src/plugin/command/workflow.md +++ b/packages/core/src/plugin/command/workflow.md @@ -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 diff --git a/packages/opencode/src/dag/blocks.ts b/packages/opencode/src/dag/blocks.ts index ef93a76c5..b88415b43 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,18 +63,52 @@ 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.", 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: - "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,11 @@ function compileBlock(objective: string, block: WorkflowBlock, blocks: WorkflowB required, reportToParent: block.report_to_parent ?? block.kind === "synthesize", condition, + outputSchema: WRITER_KINDS.has(block.kind) + ? IMPLEMENTATION_SCHEMA + : block.kind === "verify" + ? VERIFICATION_SCHEMA + : undefined, }), ] } @@ -246,6 +284,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 +315,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 +328,120 @@ 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({ + 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) + 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 +449,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/review-lifecycle.ts b/packages/opencode/src/dag/review-lifecycle.ts index b6848dd81..5ee59bc9c 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/src/dag/runtime/loop.ts b/packages/opencode/src/dag/runtime/loop.ts index cd1727edd..c5bcdfd29 100644 --- a/packages/opencode/src/dag/runtime/loop.ts +++ b/packages/opencode/src/dag/runtime/loop.ts @@ -1134,11 +1134,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) diff --git a/packages/opencode/src/session/tools.ts b/packages/opencode/src/session/tools.ts index 252afaf72..c4f237060 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 diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index c3e1ce1a4..d08a5edef 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 47485624f..31cc5b15d 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,20 @@ 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 + +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. @@ -133,9 +147,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 +169,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(Dag.ID).annotate({ + description: "(extend/control/status/result) Target workflow ID", + }), + 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({ + 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 +188,16 @@ export const Parameters = Schema.Struct({ // Tool definition // ============================================================================ -type Metadata = { workflowId?: string; added?: string[]; cancel?: string[]; restart?: string[]; replace?: string[] } +type Metadata = { + workflowId?: Dag.ID + nodeId?: Dag.NodeID + truncated?: boolean + nextCursor?: ResultCursorToken + added?: string[] + cancel?: string[] + restart?: string[] + replace?: string[] +} export const WorkflowTool = Tool.define< typeof Parameters, @@ -180,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) @@ -201,6 +233,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 +364,80 @@ 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( + 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 || + 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 + ? 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}`, + 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 +626,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 008934711..773b72605 100644 --- a/packages/opencode/test/dag/blocks.test.ts +++ b/packages/opencode/test/dag/blocks.test.ts @@ -68,32 +68,111 @@ 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("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", + 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 +210,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 72ab6b033..7417be212 100644 --- a/packages/opencode/test/dag/dag-wake-integration.test.ts +++ b/packages/opencode/test/dag/dag-wake-integration.test.ts @@ -500,6 +500,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* () { @@ -1175,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") @@ -1256,7 +1311,7 @@ describe("DagLoop atomic wake integration", () => { status: "running", config: JSON.stringify({ name: "dag_recovered_review_rejection", - mode: "deep", + mode, nodes, }), seq: 10, diff --git a/packages/opencode/test/dag/workflow-child-tools.test.ts b/packages/opencode/test/dag/workflow-child-tools.test.ts index 48f2278d8..91f57d5e2 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 75910a66a..3dbbd948d 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 = "" @@ -87,6 +88,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 +123,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, + wakeReported: true, startedAt: 1, - completedAt: null, + completedAt: 2, 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, + : 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) => @@ -375,12 +415,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: "control", workflow_id: "wf-1", operation: "pause" })).not.toThrow() - expect(() => decode({ action: "status", workflow_id: "wf-1" })).not.toThrow() + expect(() => + decode({ action: "extend", workflow_id: "dag_wf_1", spec_path: ".opencode/workflows/extend.yaml" }), + ).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() @@ -404,6 +447,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() @@ -418,33 +469,34 @@ 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", () => { 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", () => { @@ -469,7 +521,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") @@ -499,7 +551,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"), @@ -521,6 +573,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.ID.make("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 +695,45 @@ 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.ID.make("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.ID.make("dag_defaults"), spec: { nodes: [] } }, + foreignContext, + ), + ) + const controlExit = yield* Effect.exit( + workflow.execute( + { action: "control", workflow_id: Dag.ID.make("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, @@ -565,11 +746,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* () { @@ -634,7 +815,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 +825,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 +834,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", @@ -785,7 +968,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"), @@ -1298,7 +1481,7 @@ config: yield* workflow.execute( { action: "extend", - workflow_id: "dag_defaults", + workflow_id: Dag.ID.make("dag_defaults"), spec_path: specPath, }, { @@ -1357,7 +1540,7 @@ config: yield* workflow.execute( { action: "control", - workflow_id: "dag_defaults", + workflow_id: Dag.ID.make("dag_defaults"), operation: "replan", spec_path: specPath, }, @@ -1524,7 +1707,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 +1731,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 +1752,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 b4e5e23c3..11257220b 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")