From c20e71d3bd1d3564f5f5c68e9eb31a1ac47f6249 Mon Sep 17 00:00:00 2001 From: lex Date: Mon, 10 Aug 2026 10:32:18 +0800 Subject: [PATCH 1/7] feat(dag): add proactive composable routing --- packages/core/src/plugin/command.ts | 7 +- packages/core/src/plugin/command/dag-flow.txt | 70 ++-- .../plugin/command/orchestration-policy.md | 28 +- .../src/plugin/command/workflow-blocks.md | 110 ++++++ .../src/plugin/command/workflow-routing.md | 56 +++ packages/core/src/plugin/skill.ts | 18 +- .../src/plugin/skill/create-dag-workflow.md | 73 ++-- .../src/plugin/skill/orchestration-router.md | 86 ++++ packages/core/test/plugin/command.test.ts | 76 ++-- packages/core/test/plugin/skill.test.ts | 19 + packages/opencode/src/dag/blocks.ts | 294 ++++++++++++++ packages/opencode/src/dag/workflows.ts | 17 +- packages/opencode/src/skill/index.ts | 13 + packages/opencode/src/tool/workflow.ts | 366 ++++++++++++++---- packages/opencode/test/dag/blocks.test.ts | 155 ++++++++ .../opencode/test/dag/workflow-tool.test.ts | 88 ++++- packages/opencode/test/skill/skill.test.ts | 12 +- 17 files changed, 1263 insertions(+), 225 deletions(-) create mode 100644 packages/core/src/plugin/command/workflow-blocks.md create mode 100644 packages/core/src/plugin/command/workflow-routing.md create mode 100644 packages/core/src/plugin/skill/orchestration-router.md create mode 100644 packages/opencode/src/dag/blocks.ts create mode 100644 packages/opencode/test/dag/blocks.test.ts diff --git a/packages/core/src/plugin/command.ts b/packages/core/src/plugin/command.ts index 398deb79ba..e67e2bce6d 100644 --- a/packages/core/src/plugin/command.ts +++ b/packages/core/src/plugin/command.ts @@ -9,6 +9,8 @@ import PROMPT_INITIALIZE from "./command/initialize.txt" import PROMPT_REVIEW from "./command/review.txt" import DAG_FLOW_PROMPT from "./command/dag-flow.txt" import DAG_TEMPLATE_UPDATE_PROMPT from "./command/dag-template-update.txt" +import workflowRouting from "./command/workflow-routing.md" with { type: "text" } +import workflowBlocks from "./command/workflow-blocks.md" with { type: "text" } import workflowContent from "./command/workflow.md" with { type: "text" } import orchestrationPolicy from "./command/orchestration-policy.md" with { type: "text" } import orchestrationDomains from "./command/orchestration-domains.md" with { type: "text" } @@ -16,10 +18,11 @@ import orchestrationDomains from "./command/orchestration-domains.md" with { typ export const DagFlowDescription = "Start a dependency-graph multi-agent workflow for the supplied task" export const DagTemplateUpdateDescription = "Update the global DAG reference templates from opencode-dag-config" export const WorkflowFactsContent = workflowContent +export const WorkflowBlocksContent = workflowBlocks export const OrchestrationPolicyContent = orchestrationPolicy export const OrchestrationDomainsContent = orchestrationDomains -export const WorkflowContent = `${WorkflowFactsContent}\n\n${OrchestrationPolicyContent}\n\n${OrchestrationDomainsContent}` -export const DagFlowContent = `${DAG_FLOW_PROMPT}\n\n${WorkflowContent}` +export const WorkflowContent = workflowRouting +export const DagFlowContent = DAG_FLOW_PROMPT export const Plugin = define({ id: "command", diff --git a/packages/core/src/plugin/command/dag-flow.txt b/packages/core/src/plugin/command/dag-flow.txt index 033b646d63..d5edd9bd41 100644 --- a/packages/core/src/plugin/command/dag-flow.txt +++ b/packages/core/src/plugin/command/dag-flow.txt @@ -1,43 +1,39 @@ # Start a DAG Workflow -The user invoked `/dag-flow` to start a new orchestration task. - $ARGUMENTS -If the content inside `` is empty or contains only whitespace, ask the user what task should be orchestrated. Do not call the `workflow` tool until the user provides a task. - -For a non-empty task: - -1. Before starting, classify the task as `brainstorm`, `review`, or `develop`, then select the closest reference topology from the workflow library. Saved workflow names resolve through three scopes (first match wins): project `.opencode/workflows/`, global `/workflows/` (curated by the `opencode-dag-config` repo), then the builtin templates embedded in release binaries. - Run `workflow(action: "list")` to see every template that actually resolves in this environment with its scope, then pick by NAME: - - design documents, requirement deep-dives, architecture decisions, or design-level debugging → saved workflow `design-decision-loop` - - end-to-end implementation with multiple modules, wiring, tests, and review → saved workflow `parallel-development-loop` - - deep review of an already-built module, subsystem, or codebase → saved workflow `deep-review-dag-module` - - a small bounded working-tree change review → saved workflow `change-review` - - none of the above names resolves (bare dev checkout without the config repo) → compose the smallest fresh graph; do not force an unrelated reference -2. Treat the selected saved spec as a reviewed topology reference, not as a script to replay blindly. Start a saved workflow by name only when its embedded target and inputs already match the request. Otherwise read the reference, derive one inline `spec`, inject the complete `/dag-flow` task into its root planning/exploration prompt, retarget its lanes, and pass it directly to `workflow(action=start)`. Do not create a transient YAML file. -3. The derived graph may expand or prune non-protected lanes. Record the selected `reference_template`, every added node, and every prune as `{node, prune_reason, replacement_coverage}` in the first planning/exploration artifact; require the next fresh review gate to audit that manifest. Missing prune evidence is fail-closed. -4. Preserve the selected reference's protected spine: fresh-context local review, deterministic/evidence verification where applicable, one final arbiter, and PASS-only finalization. Gates return `PASS | LOOP | BLOCKED` with reason, evidence, minimal `loop_scope`, and `stop_reason`. `LOOP` means pause → replan new local correction/review nodes → resume; never create a cycle or restart terminal nodes. -5. During compilation, preserve every user constraint in the graph, including named `@agent` roles, exact model selections, read-only or "Do not modify files" scope, required checks, forbidden actions, and requested deliverables. -6. Resolve capability slots against the eligible configured worker types shown in the `workflow` tool description. Do not invent a missing role or model; if a required capability cannot be resolved, do not start and report the gap. -7. Scale one consolidated graph to the task's blast radius. Related flows for this user objective become nodes and edges under the same workflow ID. A small, well-bounded target gets the smallest useful dependency graph. A large or system-level target (an entire module, subsystem, or codebase) is never satisfied by a single wave of parallel opinions: stage exploration, independent analysis, evidence verification, and synthesis as separate dependent waves. Keep independent viewpoints or work packages parallel and use real fan-in nodes for synthesis, arbitration, integration, and final reporting. -8. For a large-target review or audit, require every reviewer to cite file:line evidence and to mark claims it could not verify. Insert a verification wave between the reviewers and the arbiter that checks disputed, unverified, and uncovered scope against the actual code, so the arbiter rules on verified findings only. -9. Call the `workflow` tool with `action=start` and inline `spec` in this response. Use `spec_path` only when the selected saved workflow already matches or persistence was explicitly requested. Merely printing a plan, graph, JSON, or YAML does not mean a workflow was started. -10. Do not claim the workflow is running unless the tool call succeeds. On success, report the exact Workflow ID and initial state returned by the tool, then tell the user to run `/dag` for live inspection. -11. The workflow runs asynchronously and wakes this parent session when attention or a terminal result is ready. Do not poll it with `action=status`, sleep, retry, or loop merely to wait. End the current response after the brief success report. -12. On failure, state that the workflow was not started and report the actual error. Never invent a Workflow ID or start a replacement workflow unless the user explicitly asked for automatic retries. -13. A completed aggregate node must actually contain the requested synthesis. Never describe unresolved placeholders or an aggregate-node error message as a successful final result. - -## Resume-first: continue an interrupted workflow before restarting - -When the current task maps to a previously interrupted workflow (same task retried or resumed), the default instinct to "restart the whole graph" is usually wrong — completed node outputs are durable and reusable. First read `workflow(action=status)` on the prior workflow: every failed node carries `error_class` (runtime classes: timeout / exec_failed / verdict_fail) plus `error_reason` — except nodes cancelled via replan (failed with reason "cancelled via replan", no error_class) and rows written before the error_class migration — triage per the Node failure triage section in the workflow guidance, then recover in this order, and only fall back to a full restart when nothing reusable exists: - -1. **Paused recovery (crash recovery)**: if the prior workflow is `paused`, never open a new one. The failed node is terminal and immutable — add a replacement node under a NEW id, rewire its pending dependents' `depends_on` to the new id, then `control(resume)`. Downstream nodes stay pending and keep their state. -2. **Continue from completed waves**: if the prior workflow is terminal (`failed` or `cancelled`) but has nodes that `completed` before the failure, their final outputs are still valid. Extract each completed node's output (its final text result, e.g. from the node session's persisted parts or any artifact it wrote) and compile a **continuation spec** that starts at the first unfinished wave. Inject the reused outputs as static context into the downstream node prompts (do not re-run them), add only the missing nodes, and `workflow(action=start)` it. Record `reused_nodes` in the manifest. -3. **Full restart**: only when no completed-node output is reusable — zero completed nodes, or their outputs are empty/irrelevant to the remaining work — re-derive the full graph and start it. - -Fail-closed guard: before starting a continuation, verify every reused output is present and non-empty; if extraction is incomplete, fall back to the affected node's fresh run rather than silently continuing on empty input. Never discard completed work to rerun it from zero unless extraction genuinely fails. - -Use the orchestration guidance below to design and manage the workflow. +If the task is empty, ask for it and do not start a workflow. Otherwise load +the `orchestration-router` skill and route the request through one consolidated +graph. `/dag-flow` explicitly selects DAG execution, but it does not waive a +material user decision. + +When the route requires a decision checkpoint or GRILL qualification, inspect +discoverable facts first, proactively write recommended answers, surface the +compact brief in the main conversation, and ask for one combined confirmation. +Do not call `workflow(action="start")` until that confirmation arrives. Do not +put the checkpoint in a child node. If the request is already bounded and +confirmed, start without manufacturing another question. + +Prefer composable blocks for a fresh flow. Load +`workflow(action="guide", topic="blocks")` only if the block contract is not +already in context. Use inline `spec` for one-off work; use `spec_path` only +when a saved workflow already matches or persistence was requested. Preserve +the task, user constraints, named roles, read-only limits, acceptance checks, +and confirmed decisions in the objective and block instructions. + +Call the workflow tool with `action=start` in the first response after the +route is ready. Printing a plan, JSON, or YAML does not start it. Never invent +worker types or model IDs. If a configured capability or model is unavailable, +report the actual gap and leave the workflow uncreated. + +On success, report the exact Workflow ID and initial state, tell the user they +can run `/dag` for live inspection, and end the response. The workflow wakes +the parent when attention is needed. Do not poll, sleep, or loop to wait. On +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. diff --git a/packages/core/src/plugin/command/orchestration-policy.md b/packages/core/src/plugin/command/orchestration-policy.md index f591c5e762..ded227f7da 100644 --- a/packages/core/src/plugin/command/orchestration-policy.md +++ b/packages/core/src/plugin/command/orchestration-policy.md @@ -99,7 +99,8 @@ graph. You MUST NOT create an admission child node, QA workflow, separate persona, or privileged command. `GRILL-ME` selects `GRILL`; equivalent explicit requests for adversarial qualification do the same. -Cover these six dimensions, asking only material unresolved questions: +Cover these six dimensions, resolving repository-discoverable facts before +asking the user: 1. goal; 2. scope; @@ -108,15 +109,23 @@ Cover these six dimensions, asking only material unresolved questions: 5. evidence and review; 6. risks and failure modes. -Use one adaptive policy with bounded modes: +Use one parent-owned recommendation and confirmation interaction. Fill every +material open decision with a recommended answer based on available evidence, +show alternatives only when they change the result, then ask the user for one +combined confirmation. Do not drip questions across several turns. A user +correction creates a revised brief and one replacement confirmation; unchanged +facts are not asked again. -- `LIGHT`: at most 1 question round for a nearly complete brief. -- `STANDARD`: at most 3 question rounds and the default for deep admission. -- `GRILL`: at most 5 question rounds, probing contradictions, hidden - assumptions, evidence quality, failure modes, and falsifiers. +The modes control challenge depth, not the number of user question rounds: -Stop early as soon as the brief is ready. Exhausting a budget with unresolved -blockers yields `NOT_READY`; it never silently yields `READY`. +- `LIGHT`: validate a nearly complete brief and expose only blockers. +- `STANDARD`: test scope, acceptance evidence, dependencies, and material + delivery risks. +- `GRILL`: additionally probe contradictions, hidden assumptions, evidence + quality, failure modes, and falsifiers, while still recommending an answer + for every surfaced choice. + +Unresolved blockers yield `NOT_READY`; they never silently yield `READY`. Maintain a versioned Requirement Brief with this structure: @@ -138,7 +147,8 @@ Maintain a versioned Requirement Brief with this structure: } ``` -Before start, show a concise brief summary and verdict: +Before start, proactively show the recommended answers, a concise brief +summary, and verdict: `READY | NOT_READY | WAIVED`, plus QA mode, brief revision, and remaining blockers. `READY` requires a non-empty goal, scope boundaries, acceptance criteria, evidence obligations, review plan, and no blocking diff --git a/packages/core/src/plugin/command/workflow-blocks.md b/packages/core/src/plugin/command/workflow-blocks.md new file mode 100644 index 0000000000..d2aaa7a6b4 --- /dev/null +++ b/packages/core/src/plugin/command/workflow-blocks.md @@ -0,0 +1,110 @@ +# Composable Workflow Blocks + +Blocks are the high-level interface for assembling a one-off workflow. The +tool compiles them into ordinary durable DAG nodes before validation and +persistence. Existing node-based YAML remains compatible. + +## Shape + +Use `objective` and `blocks` inside `config` for **start**, or alongside +`blocks` for **extend**. A replan uses the same fields inside `fragment`. + +```yaml +config: + name: implement-session-recovery + objective: Implement session recovery with focused tests and evidence-backed review. + blocks: + - id: map + kind: explore + instruction: Locate the ownership and persistence seams. + - id: design + kind: plan + depends_on: [map] + - id: implement + kind: coding + depends_on: [design] + skills: [tdd] + - id: checks + kind: verify + depends_on: [implement] + - id: decision + kind: review + depends_on: [checks] + skills: [code-review] +``` + +Each block accepts: + +- `id`: unique dependency address and the ID of its compiled exit node. +- `kind`: `explore`, `plan`, `prototype`, `debug`, `coding`, `verify`, + `review`, or `synthesize`. +- `depends_on`: upstream block IDs; omitted means a root block. +- `instruction`: target-specific text added to the built-in block contract. +- `skills`: relevant skill names the child loads lazily when available. +- `worker_type`, `required`, `report_to_parent`: optional overrides. + +`objective` is required and is injected into every generated node. Use blocks +or nodes, never both. Block IDs use letters, numbers, underscores, and hyphens. +Dependencies must be acyclic; they may name blocks in the submitted fragment +or existing durable node IDs during **extend** and replan. + +## Block contracts + +- `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. +- `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`. +- `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. + +## Composition routes + +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`. +- Hard bug: `debug` → `coding` → `verify` → `review`. +- Runnable design uncertainty: `prototype` → `plan`; keep the prototype + disposable unless the confirmed scope explicitly promotes it. +- Existing implementation review: `explore` scope lanes → `review`; add a + 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. + +## Parent decision checkpoint + +User qualification is not a DAG block. Before executable blocks start, the +parent gathers facts it can discover, creates recommended answers for every +material open decision, displays one compact decision brief, and asks for one +combined confirmation. The brief contains the recommended route, alternatives +only where they change the result, assumptions, risks, scope, and acceptance +evidence. A correction from the user updates the brief; unchanged confirmed +facts are not asked again. + +After confirmation, encode the decision in `objective` and block instructions. +If the request is already fully bounded and confirmed, do not manufacture a +redundant checkpoint. Child nodes never ask the user to make product or scope +decisions. + +## When to use low-level nodes + +Drop to `nodes` for custom template bindings, several conditional branches, +special output schemas, exact retry/cancel/restart controls, or deep diff-review +metadata. Load `guide(topic=interface)` for the full node interface and +`guide(topic=policy)` for gate and recovery contracts. Do not poll a running +workflow; reporting blocks wake the parent when a decision is actionable. diff --git a/packages/core/src/plugin/command/workflow-routing.md b/packages/core/src/plugin/command/workflow-routing.md new file mode 100644 index 0000000000..cf9d7977e4 --- /dev/null +++ b/packages/core/src/plugin/command/workflow-routing.md @@ -0,0 +1,56 @@ +# Workflow Orchestration + +In the user-facing parent session, use this tool proactively when one user +objective needs staged, parallel, quality-gated, or adaptive execution. A slash +command is not required. A DAG child session executes its assigned block and +must not recursively route that assignment into another workflow. + +## Execution Mode Selection + +- Use direct execution for conversation, a small read-only lookup, or one or + two isolated utility scripts outside a project-level change. +- Use one `task` subagent for one independent non-trivial leaf assignment. +- Use one `workflow` DAG for project-level source or test changes (even when + only one project file is expected), work that crosses module boundaries, + product/architecture planning that needs repository exploration, or any + staged/parallel/gated/adaptive objective. + +Related work for one objective belongs in one live workflow. The parent +conversation owns user decisions, scope, checkpoints, workflow control, and +the final synthesis. Child nodes own executable leaf work. Explicit requests +for “single agent”, “do not use DAG”, or direct work disable proactive DAG +selection. Read-only scope changes what nodes may do; it does not by itself +disable a useful exploration or review DAG. + +For a project-level route, load the `orchestration-router` skill before +constructing the graph. It selects the smallest useful sequence of composable +blocks and defines the one-confirmation decision checkpoint. Do not place user +questioning inside a child node. + +## Progressive guidance + +Load details only when needed: + +- **guide** without `topic`: compact topic index. +- **guide** `topic=blocks`: composable block schema and examples. +- **guide** `topic=interface`: low-level node fields and tool semantics. +- **guide** `topic=policy`: admission, gates, recovery, and bounded repair. +- **guide** `topic=patterns`: larger domain playbooks. + +## Actions + +- **start** creates one workflow from exactly one inline `spec` or saved + `spec_path`. +- **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. +- **control** pauses, resumes, cancels, replans, steps, or completes a workflow. +- **list** shows saved workflow specs and their resolution scope. + +Prefer high-level `blocks` for a fresh one-off flow. Use low-level `nodes` when +the task needs custom bindings, conditions, output schemas, or review metadata. +Never provide both. Reusable saved YAML remains valid and may use either form. + +The workflow runs asynchronously and wakes the parent at actionable reporting +nodes or terminal state. Do not poll, sleep, or loop merely to wait. Never +claim a workflow started unless **start** returned its exact workflow ID. diff --git a/packages/core/src/plugin/skill.ts b/packages/core/src/plugin/skill.ts index 43b76800ea..c027b73302 100644 --- a/packages/core/src/plugin/skill.ts +++ b/packages/core/src/plugin/skill.ts @@ -9,10 +9,12 @@ import { SkillV2 } from "../skill" import customizeOpencodeContent from "./skill/customize-opencode.md" with { type: "text" } import configureHooksContent from "./skill/configure-hooks.md" with { type: "text" } import createDagWorkflowContent from "./skill/create-dag-workflow.md" with { type: "text" } +import orchestrationRouterContent from "./skill/orchestration-router.md" with { type: "text" } export const CustomizeOpencodeContent = customizeOpencodeContent export const ConfigureHooksContent = configureHooksContent export const CreateDagWorkflowContent = createDagWorkflowContent +export const OrchestrationRouterContent = orchestrationRouterContent export const CustomizeOpencodeDescription = "Use ONLY when the user is editing or creating opencode's own configuration: opencode.json, opencode.jsonc, files under .opencode/, or files under ~/.config/opencode/. Also use when creating or fixing opencode agents, subagents, commands, skills, plugins, MCP servers, or permission rules. Do not use for the user's own application code, or for any project that is not configuring opencode itself." @@ -21,7 +23,10 @@ export const ConfigureHooksDescription = "Use when the user wants to automatically run something on an opencode event — before/after a tool call, on session start/end, on compaction, etc. — or asks about opencode's hooks / hooks.json / event hooks. Covers hooks.json file locations and format, the 27 supported events, and the 5 hook types (command, mcp, http, prompt, agent). Also use to migrate hooks from Claude Code's .claude/settings.json via /import-claude-hooks." export const CreateDagWorkflowDescription = - "Use when the user wants to create, save, or edit a reusable DAG workflow — a named multi-agent graph they can start again later — or asks where workflow specs live, how to make a workflow available in every project, or why a saved workflow name does not resolve. Covers the project (.opencode/workflows/) and global (config dir) scopes, the spec file shape, and how to verify a new workflow. Do not use to run an existing workflow or to design a one-off graph for the current task; the workflow tool handles those." + "Use when the user wants to create, save, or edit a reusable DAG workflow — a named multi-agent graph they can start again later — or asks where workflow specs live, how to make a workflow available in every project, or why a saved workflow name does not resolve. Covers project/global scopes, composable blocks, low-level nodes, and verification. Do not use to run an existing workflow or to design a one-off graph for the current task; the workflow tool handles those." + +export const OrchestrationRouterDescription = + "Use proactively in the user-facing parent session, without waiting for /dag-flow, whenever one objective changes project source or tests (even one project file), crosses module boundaries, needs repository-backed product/architecture planning, or has staged, parallel, quality-gated, or adaptive execution. Routes work through a parent-owned decision checkpoint and composable DAG blocks. Do not use inside a DAG child session, for one or two isolated utility scripts, simple lookup/conversation, or when the user explicitly requests direct work, one agent, or no DAG." export const Plugin = define({ id: "skill", @@ -60,6 +65,17 @@ export const Plugin = define({ }), }), ) + draft.source( + SkillV2.EmbeddedSource.make({ + type: "embedded", + skill: SkillV2.Info.make({ + name: "orchestration-router", + description: OrchestrationRouterDescription, + location: AbsolutePath.make("/builtin/orchestration-router.md"), + content: OrchestrationRouterContent, + }), + }), + ) }) }), }) diff --git a/packages/core/src/plugin/skill/create-dag-workflow.md b/packages/core/src/plugin/skill/create-dag-workflow.md index f0091531fc..58c23fc2ae 100644 --- a/packages/core/src/plugin/skill/create-dag-workflow.md +++ b/packages/core/src/plugin/skill/create-dag-workflow.md @@ -8,9 +8,9 @@ A saved workflow is a YAML spec that lives on disk under a name, so a recurring multi-agent procedure can be started with one call instead of being redesigned -every time. This skill covers authoring one. The `workflow` tool's own -documentation covers graph semantics — read it for node fields, collaboration -patterns, and replanning. +every time. This skill covers authoring one. Load +`workflow(action: "guide", topic: "blocks")` for the high-level interface or +`topic: "interface"` for low-level node fields and replanning. ## Where the file goes @@ -35,9 +35,9 @@ gets reused, so a wrong assumption gets repeated: 1. **The trigger.** What does the user say to run this? That phrasing should be recognizable in the workflow's `title`. 2. **The phases.** Which steps genuinely depend on an earlier step's output, and which are independent? Only real data dependencies become `depends_on` edges; everything else runs in parallel. -3. **The gate.** Is there a point where downstream work must not start until quality is confirmed? That becomes a node with `output_schema` returning a verdict plus a `condition` on its dependents. -4. **The inputs.** Does the graph need per-run values (a target module, a diff range)? A saved spec is static, so express them as static `prompt_template.input` defaults and state in the node prompt that the parent may narrow the target — or keep the node prompt broad enough to work unchanged. -5. **The finish.** What does a successful run produce, and which node reports it? Give that node `report_to_parent: true`. +3. **The gate.** Is there a point where downstream work must not start until quality is confirmed? Prefer a `review` block; use low-level nodes for custom verdict branches. +4. **The inputs.** Does the graph need per-run values? Put the stable purpose in `objective` and retargetable details in block `instruction`; use low-level template inputs only when bindings are necessary. +5. **The finish.** What does success produce? End with `review` when its verdict is the result, or `synthesize` when several accepted artifacts need a parent-facing report. ## File shape @@ -47,52 +47,27 @@ config: name: code-review max_concurrency: 5 node_defaults: - required: false - report_to_parent: false worker_config: timeout_ms: 600000 - nodes: - - id: explore - name: explore - worker_type: explore - depends_on: [] - required: true - prompt_template: - id: code-explore - input: - target: "the packages changed in the working tree" - - - id: review-logic - name: review-logic - worker_type: general - depends_on: [explore] - prompt_template: { id: review-logic } - - - id: review-arch - name: review-arch - worker_type: general - depends_on: [explore] - prompt_template: { id: review-arch } - - - id: arbitrate - name: arbitrate - worker_type: general - depends_on: [review-logic, review-arch] - required: true - report_to_parent: true - output_schema: - type: object - required: [verdict, summary, findings] - properties: - verdict: - type: string - enum: [ACCEPT, REVISE, REJECT, BLOCKED] - summary: { type: string } - findings: { type: array } - prompt_template: - inline: "Two reviewers produced findings. Submit one deduplicated verdict with evidence-backed findings." + objective: Review the working-tree change against repository standards and confirmed intent. + blocks: + - id: survey + kind: explore + instruction: Inspect the complete diff, affected modules, and repository instructions. + - id: checks + kind: verify + depends_on: [survey] + instruction: Run the documented gates from the affected package directories. + - id: decision + kind: review + depends_on: [survey, checks] ``` +Use blocks for the common explore/plan/prototype/debug/coding/verify/review/ +synthesize routes. Drop to `nodes` only for custom bindings, multiple verdict +branches, specialized output schemas, restart/cancel fragments, or deep diff +review metadata. Never declare both `blocks` and `nodes` in one graph. + `title` and `config` sit at the file root. A deep workflow adds `mode: deep` and an `admission` block at the same level — but admission answers are per-request, so a saved spec is usually `standard`; let the parent run the @@ -111,7 +86,7 @@ admission Q&A and write a one-off deep spec when depth is needed. A spec is only proven by a real start. After writing the file: -1. `workflow(action: "list")` — confirm the name resolves and the reported node count matches the file. A file missing from the listing is in the wrong directory or has the wrong extension (`.yaml`/`.yml` only). +1. `workflow(action: "list")` — confirm the name resolves and the reported block/node count matches the file. A file missing from the listing is in the wrong directory or has the wrong extension (`.yaml`/`.yml` only). 2. `workflow(action: "start", spec_path: "")` on a small, real target. Schema and graph validation happen here: an invalid spec fails the start with the offending field, and no workflow is created. 3. Read the wake report when it arrives. A graph that "succeeded" while its fan-in node produced an empty synthesis is not working — check that the reporting node's output actually contains the comparison or decision the procedure exists to produce. diff --git a/packages/core/src/plugin/skill/orchestration-router.md b/packages/core/src/plugin/skill/orchestration-router.md new file mode 100644 index 0000000000..40e63561e8 --- /dev/null +++ b/packages/core/src/plugin/skill/orchestration-router.md @@ -0,0 +1,86 @@ + + +# Orchestration Router + +Turn one user objective into the smallest execution route that preserves user +control and produces verifiable evidence. The router decides; workflow blocks +execute. Do not copy the whole playbook into the parent response. + +This skill belongs to the user-facing parent session. If the current prompt +identifies this session as a DAG child or assigns one bounded block, execute +that assignment directly and do not create a nested workflow. + +## 1. Establish facts before asking + +Read repository instructions and inspect enough code, tests, history, or +runtime evidence to answer discoverable questions yourself. Separate: + +- confirmed facts; +- decisions only the user can make; +- runnable uncertainties best answered by a disposable prototype; +- implementation work suitable for child sessions. + +Do not ask the user for file locations, conventions, or current behavior that +the repository can reveal. + +## 2. Select the execution lane + +Use direct work for conversation, a bounded lookup, or one or two isolated +utility scripts outside a project-level change. Use one task child for one +independent non-trivial leaf. Use one workflow without waiting for `/dag-flow` +whenever the objective changes project source or tests—even when only one +project file is expected—spans modules, requires repository-backed product or +architecture planning, or has staged, parallel, gated, or adaptive work. + +Honor explicit “single agent”, “do not use DAG”, and direct-execution requests. +Keep all related work for one objective under one workflow ID; adapt it with +extend/replan rather than creating disconnected graphs. + +## 3. Run one parent-owned decision checkpoint when needed + +Use a decision checkpoint for material product choices, conflicting +constraints, high-blast-radius architecture, or an explicit GRILL request. It +must happen in the parent conversation before executable DAG blocks start. + +Generate recommended answers proactively. Present one compact brief containing: + +1. recommended route and why; +2. scope in/out and acceptance evidence; +3. assumptions and risks; +4. alternatives only where the choice materially changes the result; +5. one combined confirmation request. + +Wait for that confirmation. Do not hide the recommendation inside tool output, +start speculative implementation, or delegate the questions to a child. If the +user changes an answer, revise only affected fields and ask one new combined +confirmation. If the request already supplies an equivalent confirmed brief, +do not repeat the checkpoint. + +## 4. Compose blocks from the route + +Call `workflow(action="guide", topic="blocks")` when the block interface is +not already in context. Select only justified blocks: + +- product/design: evidence lanes → competing plans when useful → synthesis or + review decision; +- feature: optional explore → plan → independent coding packages → verify → + review; +- bug: debug → coding → verify → review; +- runnable uncertainty: prototype detour → update the plan; +- review-only: scope exploration → independent review and arbitration. + +Use a skill name on a block only when it appears in the available skill +catalog. Test-first implementation and standards/spec review belong in their +respective coding and review blocks, not in the always-on router prompt. + +## 5. Preserve ownership boundaries + +The parent owns the confirmed brief, graph shape, user interaction, workflow +controls, checkpoint disposal, and final report. Children own repository +exploration, implementation, checks, and bounded review artifacts. Do not have +the parent perform executable leaf work after choosing a workflow. + +Start only after required confirmation. Report the returned workflow ID and +end the turn; the runtime wakes the parent later. On wake, dispose of a +non-ACCEPT verdict by targeted extension/replan or a reasoned stop. Never poll +merely to wait, and never describe an unstarted graph as running. diff --git a/packages/core/test/plugin/command.test.ts b/packages/core/test/plugin/command.test.ts index bff6223f04..9dfdd88e8e 100644 --- a/packages/core/test/plugin/command.test.ts +++ b/packages/core/test/plugin/command.test.ts @@ -49,21 +49,38 @@ describe("CommandPlugin.Plugin", () => { template: CommandPlugin.DagFlowContent, }) expect(CommandPlugin.DagFlowContent).toContain("$ARGUMENTS") - expect(CommandPlugin.DagFlowContent).toContain("workflow` tool with `action=start") + expect(CommandPlugin.DagFlowContent).toContain('workflow(action="start")') expect(CommandPlugin.DagFlowContent).toContain("exact Workflow ID") expect(CommandPlugin.DagFlowContent).toContain("run `/dag`") + expect(CommandPlugin.DagFlowContent).toContain("orchestration-router") + expect(CommandPlugin.DagFlowContent).toContain("one combined confirmation") }), ) it.effect("documents the smallest child execution mode", () => Effect.sync(() => { expect(CommandPlugin.WorkflowContent).toContain("## Execution Mode Selection") - expect(CommandPlugin.WorkflowContent).toContain("Use direct execution only") + expect(CommandPlugin.WorkflowContent).toContain("Use direct execution for") expect(CommandPlugin.WorkflowContent).toContain("one `task` subagent") - expect(CommandPlugin.WorkflowContent).toContain("Related flows for one user objective") + expect(CommandPlugin.WorkflowContent).toContain("Related work for one objective") expect(CommandPlugin.WorkflowFactsContent).not.toContain("when ANY") expect(CommandPlugin.WorkflowFactsContent).not.toContain("- **Multi-model**:") - expect(CommandPlugin.DagFlowContent).toContain("workflow` tool with `action=start") + expect(CommandPlugin.DagFlowContent).toContain('workflow(action="start")') + }), + ) + + it.effect("keeps always-on guidance small and loads detailed topics progressively", () => + Effect.sync(() => { + expect(CommandPlugin.WorkflowContent.length).toBeLessThan(5_000) + expect(CommandPlugin.WorkflowContent).toContain("project-level source or test changes") + expect(CommandPlugin.WorkflowContent).toContain("only one project file") + expect(CommandPlugin.WorkflowContent).toContain("isolated utility scripts") + expect(CommandPlugin.WorkflowContent).toContain("orchestration-router") + expect(CommandPlugin.WorkflowContent).toContain("**guide**") + expect(CommandPlugin.WorkflowContent).not.toContain("# Orchestration Domains") + expect(CommandPlugin.WorkflowBlocksContent).toContain("# Composable Workflow Blocks") + expect(CommandPlugin.WorkflowBlocksContent).toContain("combined confirmation") + expect(CommandPlugin.WorkflowFactsContent.length).toBeGreaterThan(CommandPlugin.WorkflowContent.length) }), ) @@ -74,7 +91,7 @@ describe("CommandPlugin.Plugin", () => { expect(CommandPlugin.OrchestrationPolicyContent).toContain("one `task` subagent") expect(CommandPlugin.OrchestrationPolicyContent).toContain("one live `workflow` DAG") expect(CommandPlugin.OrchestrationPolicyContent).toContain("one user objective") - expect(CommandPlugin.DagFlowContent).toContain("one consolidated graph") + expect(CommandPlugin.DagFlowContent).toMatch(/one consolidated\s+graph/) }), ) @@ -151,7 +168,9 @@ describe("CommandPlugin.Plugin", () => { expect(CommandPlugin.OrchestrationPolicyContent).toContain("unverified_claims") expect(CommandPlugin.OrchestrationPolicyContent).toContain("claim-verification wave") expect(CommandPlugin.OrchestrationPolicyContent).toContain("MUST NOT be a silent end of the graph") - expect(CommandPlugin.OrchestrationDomainsContent).toContain("**Verification wave (mandatory for module scope and larger)**") + expect(CommandPlugin.OrchestrationDomainsContent).toContain( + "**Verification wave (mandatory for module scope and larger)**", + ) expect(CommandPlugin.OrchestrationDomainsContent).toContain("never the end of the task") }), ) @@ -201,14 +220,16 @@ describe("CommandPlugin.Plugin", () => { expect(CommandPlugin.OrchestrationPolicyContent).toContain("## Replan Protocol (pause-first)") expect(CommandPlugin.OrchestrationPolicyContent).toContain("IMMEDIATELY issue `control(pause)`") expect(CommandPlugin.OrchestrationPolicyContent).toContain("replan is valid while paused") - expect(CommandPlugin.OrchestrationPolicyContent).toContain("Pause does not interrupt nodes that are already running") + expect(CommandPlugin.OrchestrationPolicyContent).toContain( + "Pause does not interrupt nodes that are already running", + ) expect(CommandPlugin.WorkflowFactsContent).toContain("always pause FIRST") }), ) it.effect("defines productized orchestration domain playbooks", () => Effect.sync(() => { - expect(CommandPlugin.WorkflowContent).toContain("# Orchestration Domains") + expect(CommandPlugin.WorkflowContent).not.toContain("# Orchestration Domains") expect(CommandPlugin.OrchestrationDomainsContent).toContain("## The Simulated Audit Loop") expect(CommandPlugin.OrchestrationDomainsContent).toContain("NOT a cyclic edge and NOT a harness loop") expect(CommandPlugin.OrchestrationDomainsContent).toContain("NEW ids (terminal nodes are") @@ -255,10 +276,7 @@ describe("CommandPlugin.Plugin", () => { ] for (const fixture of fixtures) { - expect( - CommandPlugin.OrchestrationPolicyContent, - fixture.name, - ).toContain(fixture.expected) + expect(CommandPlugin.OrchestrationPolicyContent, fixture.name).toContain(fixture.expected) } }), ) @@ -289,12 +307,15 @@ describe("CommandPlugin.Plugin", () => { expect(CommandPlugin.OrchestrationPolicyContent).toContain('"out": []') expect(CommandPlugin.OrchestrationPolicyContent).not.toContain("in_scope") expect(CommandPlugin.OrchestrationPolicyContent).not.toContain("out_of_scope") - expect(CommandPlugin.OrchestrationPolicyContent).toContain("`LIGHT`: at most 1 question round") - expect(CommandPlugin.OrchestrationPolicyContent).toContain("`STANDARD`: at most 3 question rounds") - expect(CommandPlugin.OrchestrationPolicyContent).toContain("`GRILL`: at most 5 question rounds") - expect(CommandPlugin.OrchestrationPolicyContent).toContain("Stop early as soon as the brief is ready") + expect(CommandPlugin.OrchestrationPolicyContent).toContain("combined confirmation") + expect(CommandPlugin.OrchestrationPolicyContent).toContain("The modes control challenge depth") + expect(CommandPlugin.OrchestrationPolicyContent).toContain("`LIGHT`: validate a nearly complete brief") + expect(CommandPlugin.OrchestrationPolicyContent).toContain("`GRILL`: additionally probe contradictions") + expect(CommandPlugin.OrchestrationPolicyContent).toContain("recommending an answer") expect(CommandPlugin.OrchestrationPolicyContent).toContain("READY | NOT_READY | WAIVED") - expect(CommandPlugin.OrchestrationPolicyContent).toContain("continue QA, reduce scope, use `standard`, or explicitly waive") + expect(CommandPlugin.OrchestrationPolicyContent).toContain( + "continue QA, reduce scope, use `standard`, or explicitly waive", + ) expect(CommandPlugin.OrchestrationPolicyContent).toContain("waiver_reason") expect(CommandPlugin.OrchestrationPolicyContent).toContain("acknowledged_risks") expect(CommandPlugin.OrchestrationPolicyContent).toContain("Material changes") @@ -320,12 +341,8 @@ describe("CommandPlugin.Plugin", () => { expect(CommandPlugin.OrchestrationPolicyContent).toContain( "REJECT → corrected implementation → verification(PASS) → new diff review", ) - expect(CommandPlugin.OrchestrationPolicyContent).toContain( - "Synthetic stress-test graphs", - ) - expect(CommandPlugin.OrchestrationPolicyContent).toContain( - "MUST NOT claim implementation-diff assurance", - ) + expect(CommandPlugin.OrchestrationPolicyContent).toContain("Synthetic stress-test graphs") + expect(CommandPlugin.OrchestrationPolicyContent).toContain("MUST NOT claim implementation-diff assurance") }), ) @@ -346,7 +363,7 @@ describe("CommandPlugin.Plugin", () => { } expect(CommandPlugin.WorkflowFactsContent).toContain("input_mapping:") expect(CommandPlugin.WorkflowFactsContent).toContain("findings: explore") - expect(CommandPlugin.WorkflowFactsContent).toContain('condition: \'gate.output.verdict == "ACCEPT"\'') + expect(CommandPlugin.WorkflowFactsContent).toContain("condition: 'gate.output.verdict == \"ACCEPT\"'") expect(CommandPlugin.WorkflowFactsContent).not.toContain('input: { findings: "from explore" }') expect(CommandPlugin.WorkflowFactsContent).not.toContain("Gate failure cancels the workflow automatically") expect(CommandPlugin.WorkflowFactsContent).toContain("Static `prompt_template.input`") @@ -358,11 +375,10 @@ describe("CommandPlugin.Plugin", () => { /`dag\.jsonc` tier, then the\s+configured agent model, then the parent-session model/, ) expect(CommandPlugin.WorkflowFactsContent).toContain("Propose-then-assemble") - const reviewExample = CommandPlugin.WorkflowFactsContent - .slice( - CommandPlugin.WorkflowFactsContent.indexOf("### 3. Adversarial Review"), - CommandPlugin.WorkflowFactsContent.indexOf("### 4. Diverge-Converge"), - ) + const reviewExample = CommandPlugin.WorkflowFactsContent.slice( + CommandPlugin.WorkflowFactsContent.indexOf("### 3. Adversarial Review"), + CommandPlugin.WorkflowFactsContent.indexOf("### 4. Diverge-Converge"), + ) expect(reviewExample).toContain("report_to_parent: true") expect(reviewExample).toContain("output_schema:") expect(reviewExample).toContain("required: [verdict, summary, findings, required_actions, next_action]") @@ -372,7 +388,7 @@ describe("CommandPlugin.Plugin", () => { // continuation node keeps non-ACCEPT verdicts from dead-ending the graph. expect(reviewExample).toContain("condition: 'arbitrate.output.verdict != \"ACCEPT\"'") expect(CommandPlugin.WorkflowFactsContent).toContain("an early\n`control(complete)` workflow remains terminal") - expect(CommandPlugin.DagFlowContent).toContain("must actually contain the requested synthesis") + expect(CommandPlugin.DagFlowContent).toContain("must contain the requested result") }), ) }) diff --git a/packages/core/test/plugin/skill.test.ts b/packages/core/test/plugin/skill.test.ts index 070752d3a0..baadb4b492 100644 --- a/packages/core/test/plugin/skill.test.ts +++ b/packages/core/test/plugin/skill.test.ts @@ -54,11 +54,30 @@ describe("SkillPlugin.Plugin", () => { expect.objectContaining({ name: "create-dag-workflow", description: expect.stringContaining("reusable DAG workflow"), + content: expect.stringContaining("Never declare both `blocks` and `nodes`"), }), ) }), ) + it.effect("registers the proactive orchestration router as a lazy built-in skill", () => + Effect.gen(function* () { + const skill = yield* SkillV2.Service + yield* SkillPlugin.Plugin.effect(host({ skill: { ...skill, reload: skill.reload } })) + + expect(yield* skill.list()).toContainEqual( + expect.objectContaining({ + name: "orchestration-router", + description: expect.stringContaining("without waiting for /dag-flow"), + content: expect.stringContaining("one combined confirmation"), + }), + ) + const router = (yield* skill.list()).find((item) => item.name === "orchestration-router") + expect(router?.description).toContain("even one project file") + expect(router?.description).toContain("isolated utility scripts") + }), + ) + it.effect("does not register workflow as a built-in skill", () => Effect.gen(function* () { const skill = yield* SkillV2.Service diff --git a/packages/opencode/src/dag/blocks.ts b/packages/opencode/src/dag/blocks.ts new file mode 100644 index 0000000000..0309a28f18 --- /dev/null +++ b/packages/opencode/src/dag/blocks.ts @@ -0,0 +1,294 @@ +import type { NodeConfig } from "./dag" + +export const WORKFLOW_BLOCK_KINDS = [ + "explore", + "plan", + "prototype", + "debug", + "coding", + "verify", + "review", + "synthesize", +] as const + +export type WorkflowBlockKind = (typeof WORKFLOW_BLOCK_KINDS)[number] + +export interface WorkflowBlock { + id: string + kind: WorkflowBlockKind + depends_on?: string[] + instruction?: string + skills?: string[] + worker_type?: string + required?: boolean + report_to_parent?: boolean +} + +export interface WorkflowBlockGraph { + objective: string + blocks: WorkflowBlock[] +} + +export interface WorkflowBlockCompileOptions { + known_dependencies?: string[] +} + +const VERDICT_SCHEMA = { + type: "object", + required: ["verdict", "summary", "findings", "required_actions"], + properties: { + verdict: { + type: "string", + enum: ["ACCEPT", "REVISE", "REJECT", "BLOCKED"], + }, + summary: { type: "string" }, + findings: { type: "array" }, + required_actions: { type: "array" }, + }, +} as const + +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.", + 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.", + 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.", + 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: + "Combine dependency outputs into one decision-ready result. Resolve conflicts using evidence, preserve material uncertainty, and state the outcome, rationale, residual risks, and next action.", +} + +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)) + const duplicateNodeIDs = uniqueDuplicates(nodes.map((node) => node.id)) + if (duplicateNodeIDs.length > 0) { + throw new Error( + `Block expansion creates duplicate node ids: ${duplicateNodeIDs.join(", ")}. Rename the colliding block`, + ) + } + return nodes +} + +function compileBlock(objective: string, block: WorkflowBlock, blocks: WorkflowBlock[]): NodeConfig[] { + const dependencies = block.depends_on ?? [] + const required = block.required ?? true + const reviewDependency = dependencies.find( + (dependency) => blocks.find((candidate) => candidate.id === dependency)?.kind === "review", + ) + const condition = reviewDependency ? `${reviewDependency}.output.verdict == "ACCEPT"` : undefined + + if (block.kind === "debug") { + const evidenceID = `${block.id}--evidence` + return [ + node({ + id: evidenceID, + name: `${block.id}: reproduce and collect evidence`, + workerType: block.worker_type ?? "explore", + dependencies, + objective, + instruction: block.instruction, + 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, + reportToParent: false, + condition, + }), + node({ + id: block.id, + name: `${block.id}: diagnose root cause`, + workerType: block.worker_type ?? "general", + dependencies: [evidenceID], + objective, + instruction: block.instruction, + skills: block.skills, + contract: BLOCK_CONTRACTS.debug, + required, + reportToParent: block.report_to_parent ?? false, + }), + ] + } + + if (block.kind === "review") { + const standardsID = `${block.id}--standards` + const intentID = `${block.id}--intent` + return [ + node({ + id: standardsID, + name: `${block.id}: standards review`, + workerType: block.worker_type ?? "general", + dependencies, + objective, + instruction: block.instruction, + skills: block.skills, + contract: `${BLOCK_CONTRACTS.review} Focus on documented repository standards, architecture constraints, correctness, and verification evidence.`, + required, + reportToParent: false, + condition, + }), + node({ + id: intentID, + name: `${block.id}: intent review`, + workerType: block.worker_type ?? "general", + dependencies, + objective, + instruction: block.instruction, + skills: block.skills, + contract: `${BLOCK_CONTRACTS.review} Focus on the confirmed goal, scope, acceptance criteria, and user-visible behavior.`, + required, + reportToParent: false, + condition, + }), + node({ + id: block.id, + name: `${block.id}: review decision`, + workerType: block.worker_type ?? "general", + dependencies: [standardsID, intentID], + 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.", + "Use ACCEPT only when no material required action remains.", + ].join(" "), + required, + reportToParent: block.report_to_parent ?? true, + outputSchema: VERDICT_SCHEMA, + }), + ] + } + + return [ + node({ + id: block.id, + name: `${block.id}: ${block.kind}`, + workerType: block.worker_type ?? workerType(block.kind), + dependencies, + objective, + instruction: block.instruction, + skills: block.skills, + contract: BLOCK_CONTRACTS[block.kind], + required, + reportToParent: block.report_to_parent ?? block.kind === "synthesize", + condition, + }), + ] +} + +function node(input: { + id: string + name: string + workerType: string + dependencies: string[] + objective: string + instruction?: string + skills?: string[] + contract: string + required: boolean + reportToParent: boolean + condition?: string + outputSchema?: Record +}): NodeConfig { + const skillInstruction = input.skills?.length + ? `Before working, load these relevant skills with the skill tool when available: ${input.skills.join(", ")}. If one is unavailable, state that limitation and continue from repository evidence.` + : "" + const instruction = input.instruction?.trim() ? "Block-specific instruction:\n{{instruction}}" : "" + return { + id: input.id, + name: input.name, + worker_type: input.workerType, + depends_on: input.dependencies, + required: input.required, + report_to_parent: input.reportToParent, + prompt_template: { + inline: [ + "Workflow objective:\n{{objective}}", + instruction, + skillInstruction, + input.contract, + "Use dependency outputs as evidence and return a concise artifact that downstream blocks can consume. Do not ask the user questions from this child session.", + ] + .filter(Boolean) + .join("\n\n"), + input: { + objective: input.objective, + ...(input.instruction?.trim() ? { instruction: input.instruction.trim() } : {}), + }, + }, + ...(input.condition ? { condition: input.condition } : {}), + ...(input.outputSchema ? { output_schema: input.outputSchema } : {}), + } +} + +function workerType(kind: WorkflowBlockKind) { + if (kind === "explore") return "explore" + if (kind === "plan") return "plan" + if (kind === "coding" || kind === "prototype") return "build" + return "general" +} + +function uniqueDuplicates(values: string[]) { + return [...new Set(values.filter((value, index) => values.indexOf(value) !== index))] +} + +function assertAcyclic(blocks: WorkflowBlock[]) { + const blockIDs = new Set(blocks.map((block) => block.id)) + const remaining = new Map( + blocks.map((block) => [ + block.id, + new Set((block.depends_on ?? []).filter((dependency) => blockIDs.has(dependency))), + ]), + ) + while (remaining.size > 0) { + const ready = [...remaining].filter(([, dependencies]) => dependencies.size === 0).map(([id]) => id) + 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) + } + } +} diff --git a/packages/opencode/src/dag/workflows.ts b/packages/opencode/src/dag/workflows.ts index dfc510b49d..b8b98d2ede 100644 --- a/packages/opencode/src/dag/workflows.ts +++ b/packages/opencode/src/dag/workflows.ts @@ -45,6 +45,8 @@ export interface Entry { readonly title?: string /** Node count, for a one-glance sense of the graph's size. */ readonly nodes?: number + /** Block count when the saved spec uses the high-level interface. */ + readonly blocks?: number } /** Builtin templates compiled into the binary from opencode-dag-config. */ @@ -147,15 +149,17 @@ function scopes(projectDir: string) { } /** Best-effort listing metadata from a file-backed spec. */ -async function describe(file: string): Promise<{ title?: string; nodes?: number }> { - const text = await Bun.file(file).text().catch(() => undefined) +async function describe(file: string): Promise<{ title?: string; nodes?: number; blocks?: number }> { + const text = await Bun.file(file) + .text() + .catch(() => undefined) return text === undefined ? {} : parseMeta(text) } /** Parse title/node metadata from spec content (shared with builtin entries). * A malformed spec still lists — hiding it would make a typo look like a * missing file; the start path reports the real parse error. */ -async function parseMeta(text: string): Promise<{ title?: string; nodes?: number }> { +async function parseMeta(text: string): Promise<{ title?: string; nodes?: number; blocks?: number }> { const parsed = await Promise.resolve(text) .then((value) => Bun.YAML.parse(value)) .catch(() => undefined) @@ -163,5 +167,10 @@ async function parseMeta(text: string): Promise<{ title?: string; nodes?: number const config = isRecord(parsed["config"]) ? parsed["config"] : undefined const title = typeof parsed["title"] === "string" ? parsed["title"] : undefined const nodes = config && Array.isArray(config["nodes"]) ? config["nodes"].length : undefined - return { ...(title ? { title } : {}), ...(nodes === undefined ? {} : { nodes }) } + const blocks = config && Array.isArray(config["blocks"]) ? config["blocks"].length : undefined + return { + ...(title ? { title } : {}), + ...(nodes === undefined ? {} : { nodes }), + ...(blocks === undefined ? {} : { blocks }), + } } diff --git a/packages/opencode/src/skill/index.ts b/packages/opencode/src/skill/index.ts index 06999e4556..4a6803a961 100644 --- a/packages/opencode/src/skill/index.ts +++ b/packages/opencode/src/skill/index.ts @@ -50,6 +50,13 @@ const CREATE_DAG_WORKFLOW_SKILL_NAME = "create-dag-workflow" const CREATE_DAG_WORKFLOW_SKILL_DESCRIPTION = SkillPlugin.CreateDagWorkflowDescription const CREATE_DAG_WORKFLOW_SKILL_BODY = SkillPlugin.CreateDagWorkflowContent +// Built-in routing skill. Its compact catalog description makes project-level +// orchestration proactive; the full decision and block-composition playbook is +// loaded only when the model invokes the skill. +const ORCHESTRATION_ROUTER_SKILL_NAME = "orchestration-router" +const ORCHESTRATION_ROUTER_SKILL_DESCRIPTION = SkillPlugin.OrchestrationRouterDescription +const ORCHESTRATION_ROUTER_SKILL_BODY = SkillPlugin.OrchestrationRouterContent + export const Info = Schema.Struct({ name: Schema.String, description: Schema.optional(Schema.String), @@ -309,6 +316,12 @@ export const layer = Layer.effect( location: "", content: CREATE_DAG_WORKFLOW_SKILL_BODY, } + s.skills[ORCHESTRATION_ROUTER_SKILL_NAME] = { + name: ORCHESTRATION_ROUTER_SKILL_NAME, + description: ORCHESTRATION_ROUTER_SKILL_DESCRIPTION, + location: "", + content: ORCHESTRATION_ROUTER_SKILL_BODY, + } yield* loadSkills(s, yield* InstanceState.get(discovered), events) return s }), diff --git a/packages/opencode/src/tool/workflow.ts b/packages/opencode/src/tool/workflow.ts index 4cae53c6e6..85dfae898b 100644 --- a/packages/opencode/src/tool/workflow.ts +++ b/packages/opencode/src/tool/workflow.ts @@ -5,6 +5,7 @@ import { Dag } from "@/dag/dag" import { DagConfig } from "@/dag/config" import { DagWorkflows } from "@/dag/workflows" import { DagModel } from "@/dag/model" +import { compileWorkflowBlocks, WORKFLOW_BLOCK_KINDS, type WorkflowBlock } from "@/dag/blocks" import { Agent } from "@/agent/agent" import { Question } from "@/question" import { Session } from "@/session/session" @@ -29,14 +30,16 @@ const NodeSchema = Schema.Struct({ worker_type: Schema.String.annotate({ description: "Agent type (explore, build, general, plan, or custom)" }), depends_on: Schema.Array(Schema.String).annotate({ description: "Node IDs this node waits for ([] for root)" }), required: Schema.optional(Schema.Boolean).annotate({ - description: "If true and this node fails, the workflow terminalizes as failed. Inherits config.node_defaults.required", + description: + "If true and this node fails, the workflow terminalizes as failed. Inherits config.node_defaults.required", }), prompt_template: Schema.Struct({ id: Schema.optional(Schema.String), inline: Schema.optional(Schema.String), input: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), }).annotate({ - description: 'Template: { id: "..." } or { inline: "...", input: {...} }. Direct dependency outputs are available as {{node-id}} by default', + description: + 'Template: { id: "..." } or { inline: "...", input: {...} }. Direct dependency outputs are available as {{node-id}} by default', }), worker_config: Schema.optional( Schema.Struct({ @@ -44,22 +47,59 @@ const NodeSchema = Schema.Struct({ }), ).annotate({ description: "{ timeout_ms } — bounds node execution. Inherits config.node_defaults.worker_config" }), input_mapping: Schema.optional(Schema.Record(Schema.String, Schema.String)).annotate({ - description: 'Optional variable-to-source map, e.g. { resultA: "node-a", count: "node-b.output.count" }. Omit to expose each direct dependency under its node ID', + description: + 'Optional variable-to-source map, e.g. { resultA: "node-a", count: "node-b.output.count" }. Omit to expose each direct dependency under its node ID', }), report_to_parent: Schema.optional(Schema.Boolean).annotate({ - description: "If true, the parent agent is woken when this node completes or fails. Inherits config.node_defaults.report_to_parent", + description: + "If true, the parent agent is woken when this node completes or fails. Inherits config.node_defaults.report_to_parent", + }), + condition: Schema.optional(Schema.String).annotate({ + description: "Expression evaluated before spawn; node is skipped if false", + }), + restart: Schema.optional(Schema.Boolean).annotate({ + description: + "(replan only) Re-spawn this running node with new prompt. Running nodes only — terminal (completed/failed/skipped) nodes are immutable; to retry a failed node, add a replacement node under a new id", }), - condition: Schema.optional(Schema.String).annotate({ description: "Expression evaluated before spawn; node is skipped if false" }), - restart: Schema.optional(Schema.Boolean).annotate({ description: "(replan only) Re-spawn this running node with new prompt. Running nodes only — terminal (completed/failed/skipped) nodes are immutable; to retry a failed node, add a replacement node under a new id" }), cancel: Schema.optional(Schema.Boolean).annotate({ description: "(replan only) Cancel this node" }), - output_schema: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)).annotate({ description: "JSON Schema; child agent must call submit_result to submit structured output" }), + output_schema: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)).annotate({ + description: "JSON Schema; child agent must call submit_result to submit structured output", + }), review: Schema.optional( Schema.Struct({ phase: Schema.Literals(["design", "diff"]), implementation_node_id: Schema.optional(Schema.String), verification_node_id: Schema.optional(Schema.String), }), - ).annotate({ description: '(deep review workers) design reviews pre-implementation artifacts; diff reviews require implementation_node_id and verification_node_id' }), + ).annotate({ + description: + "(deep review workers) design reviews pre-implementation artifacts; diff reviews require implementation_node_id and verification_node_id", + }), +}) + +const BlockSchema = Schema.Struct({ + 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", + }), + depends_on: Schema.optional(Schema.Array(Schema.String)).annotate({ + description: "Block IDs this block waits for. Defaults to []", + }), + instruction: Schema.optional(Schema.String).annotate({ + description: "Task-specific instruction added to the block's built-in execution contract", + }), + skills: Schema.optional(Schema.Array(Schema.String)).annotate({ + description: "Relevant skills the child should load lazily before working", + }), + worker_type: Schema.optional(Schema.String).annotate({ + description: "Optional configured agent override; defaults from the block kind", + }), + required: Schema.optional(Schema.Boolean).annotate({ + description: "Whether execution failure is terminal. Defaults to true for compiled blocks", + }), + report_to_parent: Schema.optional(Schema.Boolean).annotate({ + description: "Override wake behavior. Review decisions and synthesis report by default", + }), }) const WorkflowGraphSchema = Schema.Struct({ @@ -78,9 +118,21 @@ const WorkflowGraphSchema = Schema.Struct({ description: "Defaults inherited by nodes that omit required, worker_config, or report_to_parent", }), max_concurrency: Schema.optional(Schema.Number).annotate({ description: "Max parallel nodes. Default: 5" }), - max_node_replan_attempts: Schema.optional(Schema.Number).annotate({ description: "Max replan restarts per node ID. Default: 5" }), - max_total_nodes: Schema.optional(Schema.Number).annotate({ description: "Cumulative node cap across the workflow lifetime. Default: 100" }), - nodes: Schema.Array(NodeSchema).annotate({ description: "Node declarations" }), + max_node_replan_attempts: Schema.optional(Schema.Number).annotate({ + description: "Max replan restarts per node ID. Default: 5", + }), + max_total_nodes: Schema.optional(Schema.Number).annotate({ + description: "Cumulative node cap across the workflow lifetime. Default: 100", + }), + objective: Schema.optional(Schema.String).annotate({ + description: "Required when using blocks; injected into every generated child prompt", + }), + blocks: Schema.optional(Schema.Array(BlockSchema)).annotate({ + description: "High-level graph compiled into nodes. Use blocks or nodes, never both", + }), + nodes: Schema.optional(Schema.Array(NodeSchema)).annotate({ + description: "Low-level node declarations. Use nodes or blocks, never both", + }), }) // Exported so the committed workflow library can be validated in tests. @@ -92,7 +144,9 @@ export const StartSpec = Schema.Struct({ }) const ExtendSpec = Schema.Struct({ - nodes: Schema.Array(NodeSchema), + objective: Schema.optional(Schema.String), + blocks: Schema.optional(Schema.Array(BlockSchema)), + nodes: Schema.optional(Schema.Array(NodeSchema)), }) const ReplanSpec = Schema.Struct({ @@ -104,13 +158,32 @@ const decodeExtendSpec = Schema.decodeUnknownEffect(ExtendSpec) const decodeReplanSpec = Schema.decodeUnknownEffect(ReplanSpec) export const Parameters = Schema.Struct({ - action: Schema.Literals(["start", "extend", "control", "status", "list"]).annotate({ description: "start: create workflow; extend: add nodes; control: pause/resume/cancel/replan/step/complete; status: inspect durable workflow and node state; list: show saved workflow specs in the library (not running workflows)" }), - spec: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)).annotate({ description: "(start/extend/control replan) Inline structured spec for a one-off graph. Use this or spec_path, never both" }), - spec_path: Schema.optional(Schema.String).annotate({ description: '(start/extend/control replan) A saved workflow name from the library (e.g. "code-review"), or a path to a YAML workflow spec. Relative paths resolve from the session directory' }), - session_id: Schema.optional(Schema.String).annotate({ description: "(start) Parent session ID; when provided, it must match the calling session" }), - project_id: Schema.optional(Schema.String).annotate({ description: "(start) Optional Project ID; must match the parent session project" }), + action: Schema.Literals(["start", "extend", "control", "status", "list", "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; guide: load detailed guidance only when needed", + }), + topic: Schema.optional(Schema.Literals(["blocks", "interface", "policy", "patterns"])).annotate({ + description: + "(guide) blocks: composable block schema; interface: low-level workflow API; policy: gates/admission/recovery; patterns: domain playbooks. Omit for the compact index", + }), + spec: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)).annotate({ + description: + "(start/extend/control replan) Inline structured spec for a one-off graph. Use this or spec_path, never both", + }), + spec_path: Schema.optional(Schema.String).annotate({ + description: + '(start/extend/control replan) A saved workflow name from the library (e.g. "code-review"), or a path to a YAML workflow spec. Relative paths resolve from the session directory', + }), + session_id: Schema.optional(Schema.String).annotate({ + description: "(start) Parent session ID; when provided, it must match the calling session", + }), + 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" }), - operation: Schema.optional(Schema.Literals(["pause", "resume", "cancel", "replan", "step", "complete"])).annotate({ description: "(control) Operation to perform" }), + operation: Schema.optional(Schema.Literals(["pause", "resume", "cancel", "replan", "step", "complete"])).annotate({ + description: "(control) Operation to perform", + }), }) // ============================================================================ @@ -148,6 +221,32 @@ export const WorkflowTool = Tool.define< execute: (params: Schema.Schema.Type, ctx: Tool.Context) => Effect.gen(function* () { switch (params.action) { + case "guide": { + if (!params.topic) { + return { + title: "Workflow guide topics", + output: [ + "Load only the topic needed for the current decision:", + "- blocks: compose explore/plan/prototype/debug/coding/verify/review/synthesize blocks", + "- interface: low-level node fields, bindings, model resolution, and tool actions", + "- policy: deep admission, gates, checkpoints, recovery, and bounded repair", + "- patterns: larger review, engineering, diagnosis, and audit topologies", + ].join("\n"), + metadata: {}, + } + } + const content = { + blocks: CommandPlugin.WorkflowBlocksContent, + interface: CommandPlugin.WorkflowFactsContent, + policy: CommandPlugin.OrchestrationPolicyContent, + patterns: CommandPlugin.OrchestrationDomainsContent, + }[params.topic] + return { + title: `Workflow guide: ${params.topic}`, + output: content, + metadata: {}, + } + } case "list": { const session = yield* sessions.get(SessionID.make(ctx.sessionID)).pipe(Effect.orDie) const entries = yield* DagWorkflows.list(session.directory) @@ -165,7 +264,11 @@ export const WorkflowTool = Tool.define< [ `${entry.name} [${entry.scope}]`, entry.title ? ` — ${entry.title}` : "", - entry.nodes === undefined ? "" : ` (${entry.nodes} nodes)`, + entry.nodes !== undefined + ? ` (${entry.nodes} nodes)` + : entry.blocks !== undefined + ? ` (${entry.blocks} blocks)` + : "", `\n ${entry.path}`, ].join(""), ) @@ -230,58 +333,67 @@ export const WorkflowTool = Tool.define< if (params.project_id && params.project_id !== session.projectID) { return yield* Effect.die(new Error("project_id must match the parent session project")) } - const specFile = yield* readWorkflowSpec(params.spec, params.spec_path, session.directory, ctx).pipe(Effect.orDie) + const specFile = yield* readWorkflowSpec(params.spec, params.spec_path, session.directory, ctx).pipe( + Effect.orDie, + ) const spec = yield* decodeStartSpec(specFile.value).pipe( Effect.mapError((error) => new Error(`Invalid workflow spec ${specFile.path}: ${String(error)}`)), Effect.orDie, ) + const config = yield* compileGraph(spec.config, specFile.path).pipe(Effect.orDie) const missingModels = yield* findNodesWithoutModel({ - nodes: spec.config.nodes, - defaults: spec.config.node_defaults, + nodes: config.nodes, + defaults: config.node_defaults, directory: session.directory, parent: session.model, agents, }) if (missingModels.length > 0) { - yield* question.ask({ - sessionID, - questions: [{ - header: "DAG model", - question: `No model is available for DAG node${missingModels.length > 1 ? "s" : ""} ${missingModels.map((node) => `"${node}"`).join(", ")}. Configure the advanced/standard tiers in dag.jsonc, a model on the selected worker agent, or a parent-session model before starting. How would you like to proceed?`, - custom: false, - options: [ + yield* question + .ask({ + sessionID, + questions: [ { - label: "Configure first", - description: "Do not start the workflow; configure a model and retry.", - }, - { - label: "Cancel workflow", - description: "Abandon this workflow start.", + header: "DAG model", + question: `No model is available for DAG node${missingModels.length > 1 ? "s" : ""} ${missingModels.map((node) => `"${node}"`).join(", ")}. Configure the advanced/standard tiers in dag.jsonc, a model on the selected worker agent, or a parent-session model before starting. How would you like to proceed?`, + custom: false, + options: [ + { + label: "Configure first", + description: "Do not start the workflow; configure a model and retry.", + }, + { + label: "Cancel workflow", + description: "Abandon this workflow start.", + }, + ], }, ], - }], - tool: ctx.callID ? { messageID: ctx.messageID, callID: ctx.callID } : undefined, - }).pipe(Effect.orDie) + tool: ctx.callID ? { messageID: ctx.messageID, callID: ctx.callID } : undefined, + }) + .pipe(Effect.orDie) return { title: "Workflow not started: model required", output: `No workflow was created. Missing model for: ${missingModels.join(", ")}. Configure dag.jsonc, the worker agent, or the parent session, then retry.`, metadata: {}, } } - const dagID = yield* dag.create({ - projectID: session.projectID, - sessionID, - title: spec.title ?? spec.config.name, - config: { - ...spec.config, - mode: spec.mode ?? "standard", - ...(spec.admission ? { admission: createAdmissionRecord(spec.admission) } : {}), - } as WorkflowConfig, - }).pipe(Effect.orDie) + const dagID = yield* dag + .create({ + projectID: session.projectID, + sessionID, + title: spec.title ?? config.name, + config: { + ...config, + mode: spec.mode ?? "standard", + ...(spec.admission ? { admission: createAdmissionRecord(spec.admission) } : {}), + } as WorkflowConfig, + }) + .pipe(Effect.orDie) const mode = spec.mode ?? "standard" return { - title: `Workflow started: ${spec.config.name}`, - output: `\n${spec.config.nodes.length} nodes registered.\nDo not poll this workflow. It runs asynchronously and will wake the parent session when attention is required.\n`, + title: `Workflow started: ${config.name}`, + output: `\n${config.nodes.length} nodes registered.\nDo not poll this workflow. It runs asynchronously and will wake the parent session when attention is required.\n`, metadata: { workflowId: dagID } as Metadata, } } @@ -289,13 +401,19 @@ export const WorkflowTool = Tool.define< if (!params.workflow_id) return yield* Effect.die(new Error("extend requires 'workflow_id'")) yield* requireOwnedWorkflow(params.workflow_id, ctx.sessionID) const session = yield* sessions.get(SessionID.make(ctx.sessionID)).pipe(Effect.orDie) - const specFile = yield* readWorkflowSpec(params.spec, params.spec_path, session.directory, ctx).pipe(Effect.orDie) + const specFile = yield* readWorkflowSpec(params.spec, params.spec_path, session.directory, ctx).pipe( + Effect.orDie, + ) const spec = yield* decodeExtendSpec(specFile.value).pipe( Effect.mapError((error) => new Error(`Invalid workflow spec ${specFile.path}: ${String(error)}`)), Effect.orDie, ) + const knownDependencies = (yield* dag.store.getNodes(params.workflow_id).pipe(Effect.orDie)).map( + (node) => node.id, + ) + const nodes = yield* compileNodeSource(spec, specFile.path, knownDependencies).pipe(Effect.orDie) const r = yield* withTerminalRecovery( - dag.extend(params.workflow_id, spec.nodes as NodeConfig[]), + dag.extend(params.workflow_id, nodes), "Terminal workflows are immutable except for the additive-extend reopen, which requires the workflow to have completed naturally at a wake-eligible reporting checkpoint (fragment adds new node ids; no early control(complete); no executed node beyond the checkpoint — condition-skipped dependents are fine). When the reopen does not apply, recover by starting a NEW workflow spec that reuses this workflow's completed outputs as static input.", ).pipe(Effect.orDie) return { @@ -306,40 +424,67 @@ export const WorkflowTool = Tool.define< } case "control": { if (!params.workflow_id || !params.operation) { - return yield* Effect.die(new Error( - `control requires 'workflow_id' and 'operation' (got workflow_id=${params.workflow_id ?? ""}, operation=${params.operation ?? ""}). Example: { action: "control", workflow_id: "dag_...", operation: "pause" }. On a cancel/replan intent, issue pause FIRST — it needs no spec and freezes scheduling instantly while you compose the replan.`, - )) + return yield* Effect.die( + new Error( + `control requires 'workflow_id' and 'operation' (got workflow_id=${params.workflow_id ?? ""}, operation=${params.operation ?? ""}). Example: { action: "control", workflow_id: "dag_...", operation: "pause" }. On a cancel/replan intent, issue pause FIRST — it needs no spec and freezes scheduling instantly while you compose the replan.`, + ), + ) } const wfId = params.workflow_id yield* requireOwnedWorkflow(wfId, ctx.sessionID) switch (params.operation) { case "pause": yield* dag.pause(wfId).pipe(Effect.orDie) - return { title: "Workflow paused", output: `\nNote: pause stops new node spawns only — nodes already running continue to completion. To stop a running node, submit a replan spec marking it restart: true or cancel: true (replan is valid while paused).`, metadata: { workflowId: wfId } as Metadata } + return { + title: "Workflow paused", + output: `\nNote: pause stops new node spawns only — nodes already running continue to completion. To stop a running node, submit a replan spec marking it restart: true or cancel: true (replan is valid while paused).`, + metadata: { workflowId: wfId } as Metadata, + } case "resume": yield* dag.resume(wfId).pipe(Effect.orDie) - return { title: "Workflow resumed", output: ``, metadata: { workflowId: wfId } as Metadata } + return { + title: "Workflow resumed", + output: ``, + metadata: { workflowId: wfId } as Metadata, + } case "cancel": yield* dag.cancel(wfId).pipe(Effect.orDie) - return { title: "Workflow cancelled", output: ``, metadata: { workflowId: wfId } as Metadata } + return { + title: "Workflow cancelled", + output: ``, + metadata: { workflowId: wfId } as Metadata, + } case "complete": yield* dag.complete(wfId).pipe(Effect.orDie) - return { title: "Workflow completed (early)", output: ``, metadata: { workflowId: wfId } as Metadata } + return { + title: "Workflow completed (early)", + output: ``, + metadata: { workflowId: wfId } as Metadata, + } case "replan": { const session = yield* sessions.get(SessionID.make(ctx.sessionID)).pipe(Effect.orDie) - const specFile = yield* readWorkflowSpec(params.spec, params.spec_path, session.directory, ctx).pipe(Effect.orDie) + const specFile = yield* readWorkflowSpec(params.spec, params.spec_path, session.directory, ctx).pipe( + Effect.orDie, + ) const spec = yield* decodeReplanSpec(specFile.value).pipe( Effect.mapError((error) => new Error(`Invalid workflow spec ${specFile.path}: ${String(error)}`)), Effect.orDie, ) + const knownDependencies = (yield* dag.store.getNodes(wfId).pipe(Effect.orDie)).map((node) => node.id) + const fragment = yield* compileGraph(spec.fragment, specFile.path, knownDependencies).pipe( + Effect.orDie, + ) // The graph raced to terminal while the fragment was being // composed (the pause-first protocol was skipped). Surface // the recovery options instead of a bare iron-law rejection. const r = yield* withTerminalRecovery( - dag.replan(wfId, { nodes: spec.fragment.nodes as NodeConfig[] }), + dag.replan(wfId, { nodes: fragment.nodes }), "The workflow reached a terminal status before the replan arrived — terminal workflows are immutable. Recover by starting a new workflow with the updated node definitions, or extend if a reporting leaf checkpoint naturally completed the graph. Next time issue control(pause) BEFORE composing the spec.", ).pipe(Effect.orDie) - const ignored = r.ignore.length > 0 ? `\nIgnored (terminal, immutable — add replacements under new ids to retry): ${r.ignore.join(", ")}` : "" + const ignored = + r.ignore.length > 0 + ? `\nIgnored (terminal, immutable — add replacements under new ids to retry): ${r.ignore.join(", ")}` + : "" return { title: `Workflow replanned: +${r.add.length} -${r.cancel.length} ↻${r.restart.length}`, output: `\nAdded: ${r.add.join(", ")}\nCancelled: ${r.cancel.join(", ")}\nRestarted: ${r.restart.join(", ")}\nReplaced: ${r.replace.join(", ")}${ignored}\n`, @@ -349,9 +494,17 @@ export const WorkflowTool = Tool.define< case "step": { const r = yield* dag.step(wfId).pipe(Effect.orDie) if (r.status === "no_ready_nodes") { - return { title: "Workflow step: no ready nodes", output: ``, metadata: { workflowId: wfId } as Metadata } + return { + title: "Workflow step: no ready nodes", + output: ``, + metadata: { workflowId: wfId } as Metadata, + } + } + return { + title: `Workflow stepped: ${r.nodeID ?? "no node"}`, + output: ``, + metadata: { workflowId: wfId, ...r } as Metadata, } - return { title: `Workflow stepped: ${r.nodeID ?? "no node"}`, output: ``, metadata: { workflowId: wfId, ...r } as Metadata } } } } @@ -361,6 +514,43 @@ export const WorkflowTool = Tool.define< }), ) +type WorkflowGraphInput = Schema.Schema.Type +type NodeSource = Pick + +function compileGraph(graph: WorkflowGraphInput, source: string, knownDependencies?: string[]) { + return Effect.gen(function* () { + const nodes = yield* compileNodeSource(graph, source, knownDependencies) + const { objective: _objective, blocks: _blocks, nodes: _nodes, ...config } = graph + return { + ...config, + nodes, + } as WorkflowConfig + }) +} + +function compileNodeSource(source: NodeSource, path: string, knownDependencies?: string[]) { + return Effect.try({ + try: () => { + const hasNodes = source.nodes !== undefined + const hasBlocks = source.blocks !== undefined + if (hasNodes === hasBlocks) { + throw new Error("use exactly one of nodes or blocks") + } + if (source.nodes) return source.nodes as NodeConfig[] + if (!source.objective) throw new Error("blocks require objective") + return compileWorkflowBlocks( + { + objective: source.objective, + blocks: source.blocks as WorkflowBlock[], + }, + { known_dependencies: knownDependencies }, + ) + }, + catch: (error) => + new Error(`Invalid workflow graph ${path}: ${error instanceof Error ? error.message : String(error)}`), + }) +} + function readWorkflowSpec( spec: Record | undefined, specPath: string | undefined, @@ -369,15 +559,17 @@ function readWorkflowSpec( ) { return Effect.gen(function* () { if (spec && specPath) { - return yield* Effect.fail(new Error( - "Workflow configuration accepts exactly one source: remove either 'spec' or 'spec_path'.", - )) + return yield* Effect.fail( + new Error("Workflow configuration accepts exactly one source: remove either 'spec' or 'spec_path'."), + ) } if (spec) return { path: "", value: spec } if (!specPath) { - return yield* Effect.fail(new Error( - `Workflow configuration requires exactly one of 'spec' or 'spec_path'. Pass an inline structured spec for a one-off graph, or a saved workflow name/path through 'spec_path'.`, - )) + return yield* Effect.fail( + new Error( + `Workflow configuration requires exactly one of 'spec' or 'spec_path'. Pass an inline structured spec for a one-off graph, or a saved workflow name/path through 'spec_path'.`, + ), + ) } const filepath = yield* resolveSpecPath(specPath, directory, ctx) @@ -399,9 +591,9 @@ function readWorkflowSpec( return yield* Effect.fail(new Error(`Workflow spec not found: ${filepath}`)) } if (file.size > MAX_WORKFLOW_SPEC_BYTES) { - return yield* Effect.fail(new Error( - `Workflow spec is too large: ${file.size} bytes exceeds ${MAX_WORKFLOW_SPEC_BYTES}`, - )) + return yield* Effect.fail( + new Error(`Workflow spec is too large: ${file.size} bytes exceeds ${MAX_WORKFLOW_SPEC_BYTES}`), + ) } const content = yield* Effect.tryPromise({ try: () => file.text(), @@ -433,9 +625,11 @@ function resolveSpecPath(specPath: string, directory: string, ctx: Tool.Context) if (DagWorkflows.isName(specPath)) { const entry = yield* DagWorkflows.resolve(specPath, directory) if (entry) return entry.path - return yield* Effect.fail(new Error( - `Saved workflow not found: "${specPath}". Searched ${searchedScopes(directory)}. Run workflow(action: "list") to see what is available, or pass a path to a .yaml spec file.`, - )) + return yield* Effect.fail( + new Error( + `Saved workflow not found: "${specPath}". Searched ${searchedScopes(directory)}. Run workflow(action: "list") to see what is available, or pass a path to a .yaml spec file.`, + ), + ) } const filepath = path.isAbsolute(specPath) ? path.normalize(specPath) : path.resolve(directory, specPath) if (![".yaml", ".yml"].includes(path.extname(filepath).toLowerCase())) { @@ -483,16 +677,16 @@ function findNodesWithoutModel(input: { Effect.map((info) => info as Agent.Info | undefined), Effect.catchCause(() => Effect.succeed(undefined)), ) - return DagModel.resolve({ - tier: DagConfig.tierModel(config, { - required: node.required ?? input.defaults?.required ?? Dag.DEFAULT_WORKFLOW_CONFIG.nodeRequired, - workerType: node.worker_type, - }), - agent: agent?.model, - parent: input.parent - ? { modelID: input.parent.id, providerID: input.parent.providerID } - : undefined, - }) === undefined + return ( + DagModel.resolve({ + tier: DagConfig.tierModel(config, { + required: node.required ?? input.defaults?.required ?? Dag.DEFAULT_WORKFLOW_CONFIG.nodeRequired, + workerType: node.worker_type, + }), + agent: agent?.model, + parent: input.parent ? { modelID: input.parent.id, providerID: input.parent.providerID } : undefined, + }) === undefined + ) }), { concurrency: "unbounded" }, ).pipe(Effect.map((nodes) => nodes.map((node) => node.id))) diff --git a/packages/opencode/test/dag/blocks.test.ts b/packages/opencode/test/dag/blocks.test.ts new file mode 100644 index 0000000000..bdff18566b --- /dev/null +++ b/packages/opencode/test/dag/blocks.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, it } from "bun:test" +import { compileWorkflowBlocks } from "@/dag/blocks" + +describe("workflow blocks", () => { + it("compiles a staged route and carries objective, instructions, skills, and dependencies", () => { + const nodes = compileWorkflowBlocks({ + objective: "Add durable session recovery", + blocks: [ + { + id: "map", + kind: "explore", + instruction: "Locate persistence ownership", + }, + { + id: "build", + kind: "coding", + depends_on: ["map"], + skills: ["tdd"], + }, + { + id: "verify", + kind: "verify", + depends_on: ["build"], + }, + ], + }) + + expect(nodes.map((node) => ({ id: node.id, worker: node.worker_type, dependsOn: node.depends_on }))).toEqual([ + { id: "map", worker: "explore", dependsOn: [] }, + { id: "build", worker: "build", dependsOn: ["map"] }, + { id: "verify", worker: "general", dependsOn: ["build"] }, + ]) + expect(nodes[0]?.prompt_template.input).toEqual({ + objective: "Add durable session recovery", + instruction: "Locate persistence ownership", + }) + expect(nodes[1]?.prompt_template.inline).toContain("load these relevant skills") + expect(nodes[1]?.prompt_template.inline).toContain("tdd") + expect(nodes.every((node) => node.required)).toBe(true) + }) + + it("expands debug into evidence and diagnosis nodes", () => { + const nodes = compileWorkflowBlocks({ + objective: "Find the source of a timeout", + blocks: [{ id: "root-cause", kind: "debug", report_to_parent: true }], + }) + + expect(nodes.map((node) => node.id)).toEqual(["root-cause--evidence", "root-cause"]) + expect(nodes[0]).toMatchObject({ + worker_type: "explore", + depends_on: [], + report_to_parent: false, + }) + expect(nodes[1]).toMatchObject({ + worker_type: "general", + depends_on: ["root-cause--evidence"], + report_to_parent: true, + }) + }) + + it("expands review into two independent lanes and a reporting verdict gate", () => { + const nodes = compileWorkflowBlocks({ + objective: "Review the implementation", + blocks: [ + { id: "implementation", kind: "coding" }, + { id: "decision", kind: "review", depends_on: ["implementation"] }, + { id: "report", kind: "synthesize", depends_on: ["decision"] }, + ], + }) + + expect(nodes.map((node) => node.id)).toEqual([ + "implementation", + "decision--standards", + "decision--intent", + "decision", + "report", + ]) + expect(nodes.find((node) => node.id === "decision")).toMatchObject({ + depends_on: ["decision--standards", "decision--intent"], + required: true, + report_to_parent: true, + output_schema: { + type: "object", + properties: { + verdict: { enum: ["ACCEPT", "REVISE", "REJECT", "BLOCKED"] }, + }, + }, + }) + expect(nodes.find((node) => node.id === "report")?.condition).toBe('decision.output.verdict == "ACCEPT"') + }) + + it("rejects ambiguous dependencies and expansion collisions", () => { + expect(() => + compileWorkflowBlocks({ + objective: "Invalid graph", + blocks: [{ id: "build", kind: "coding", depends_on: ["missing"] }], + }), + ).toThrow('Block "build" depends on unknown block "missing"') + + expect(() => + compileWorkflowBlocks({ + objective: "Colliding graph", + blocks: [ + { id: "check", kind: "review" }, + { id: "check--intent", kind: "verify" }, + ], + }), + ).toThrow("Block expansion creates duplicate node ids: check--intent") + + expect(() => + compileWorkflowBlocks({ + objective: "Cyclic graph", + blocks: [ + { id: "a", kind: "plan", depends_on: ["b"] }, + { id: "b", kind: "plan", depends_on: ["a"] }, + ], + }), + ).toThrow("dependency cycle") + + expect(() => + compileWorkflowBlocks({ + objective: "Unsafe ID", + blocks: [{ id: "review.output", kind: "review" }], + }), + ).toThrow("must use only letters") + }) + + it("allows an extension block to depend on an existing durable node", () => { + const nodes = compileWorkflowBlocks( + { + objective: "Continue from durable evidence", + blocks: [{ id: "repair", kind: "coding", depends_on: ["existing-evidence"] }], + }, + { known_dependencies: ["existing-evidence"] }, + ) + + expect(nodes[0]?.depends_on).toEqual(["existing-evidence"]) + }) + + it("requires one objective and one review dependency per continuation", () => { + expect(() => compileWorkflowBlocks({ objective: " ", blocks: [{ id: "x", kind: "coding" }] })).toThrow( + "non-empty objective", + ) + expect(() => + compileWorkflowBlocks({ + objective: "Ambiguous gates", + blocks: [ + { id: "review-a", kind: "review" }, + { id: "review-b", kind: "review" }, + { id: "build", kind: "coding", depends_on: ["review-a", "review-b"] }, + ], + }), + ).toThrow("depends on multiple review gates") + }) +}) diff --git a/packages/opencode/test/dag/workflow-tool.test.ts b/packages/opencode/test/dag/workflow-tool.test.ts index 402136775e..5a37b50d6b 100644 --- a/packages/opencode/test/dag/workflow-tool.test.ts +++ b/packages/opencode/test/dag/workflow-tool.test.ts @@ -373,7 +373,7 @@ function toolContext() { } describe("workflow tool schema (negative tests)", () => { - it("action field accepts start/extend/control/status/list", () => { + it("action field accepts start/extend/control/status/list/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() @@ -381,6 +381,7 @@ describe("workflow tool schema (negative tests)", () => { expect(() => decode({ action: "status", workflow_id: "wf-1" })).not.toThrow() // list browses the saved-spec library and needs no workflow_id. expect(() => decode({ action: "list" })).not.toThrow() + expect(() => decode({ action: "guide", topic: "blocks" })).not.toThrow() }) it("retains an inline structured spec", () => { @@ -449,7 +450,7 @@ describe("workflow tool execution", () => { const info = yield* WorkflowTool const workflow = yield* info.init() - for (const action of ["start", "extend", "status", "control"]) { + for (const action of ["guide", "start", "extend", "status", "control"]) { expect(workflow.description).toContain(`**${action}**`) } expect(workflow.description).toContain("Do not poll") @@ -457,6 +458,21 @@ describe("workflow tool execution", () => { }), ) + runtime.effect("loads detailed workflow guidance by topic instead of in the always-on description", () => + Effect.gen(function* () { + const info = yield* WorkflowTool + const workflow = yield* info.init() + const index = yield* workflow.execute({ action: "guide" }, toolContext()) + const blocks = yield* workflow.execute({ action: "guide", topic: "blocks" }, toolContext()) + + expect(workflow.description.length).toBeLessThan(5_000) + expect(index.output).toContain("blocks: compose") + expect(index.output).not.toContain("# Composable Workflow Blocks") + expect(blocks.output).toContain("# Composable Workflow Blocks") + expect(blocks.output).toContain("kind: coding") + }), + ) + runtime.effect("status returns the durable workflow and node state", () => Effect.gen(function* () { const info = yield* WorkflowTool @@ -585,6 +601,45 @@ describe("workflow tool execution", () => { }), ) + runtime.effect("starts from composable blocks and persists only compiled nodes", () => + Effect.gen(function* () { + published.length = 0 + const info = yield* WorkflowTool + const workflow = yield* info.init() + const result = yield* workflow.execute( + Schema.decodeUnknownSync(Parameters)({ + action: "start", + spec: { + config: { + name: "block-start", + objective: "Implement and review session recovery", + blocks: [ + { id: "build", kind: "coding", skills: ["tdd"] }, + { id: "review", kind: "review", depends_on: ["build"] }, + ], + }, + }, + }), + toolContext(), + ) + + expect(result.title).toBe("Workflow started: block-start") + expect(result.output).toContain("4 nodes registered") + const created = published.find((event) => event.type === DagEvent.WorkflowCreated.type)?.data as { + config?: string + } + const config = JSON.parse(created.config ?? "{}") + expect(config).not.toHaveProperty("blocks") + expect(config).not.toHaveProperty("objective") + expect(config.nodes.map((node: { id: string }) => node.id)).toEqual([ + "build", + "review--standards", + "review--intent", + "review", + ]) + }), + ) + runtime.effect("extends from an inline structured spec without a file", () => Effect.gen(function* () { published.length = 0 @@ -614,6 +669,30 @@ describe("workflow tool execution", () => { }), ) + runtime.effect("extends with blocks that depend on an existing durable node", () => + Effect.gen(function* () { + published.length = 0 + const info = yield* WorkflowTool + const workflow = yield* info.init() + const result = yield* workflow.execute( + Schema.decodeUnknownSync(Parameters)({ + action: "extend", + workflow_id: "dag_status", + spec: { + objective: "Repair from the current diagnostic evidence", + blocks: [{ id: "repair", kind: "coding", depends_on: ["node_running"] }], + }, + }), + toolContext(), + ) + + expect(result.title).toBe("Workflow extended: 1 nodes added") + expect(published.find((event) => event.type === DagEvent.NodeRegistered.type)?.data).toEqual( + expect.objectContaining({ nodeID: "repair", dependsOn: ["node_running"] }), + ) + }), + ) + runtime.effect("replans from an inline structured spec without a file", () => Effect.gen(function* () { published.length = 0 @@ -1463,6 +1542,10 @@ describe("workflow tool saved workflows", () => { Promise.all([ Bun.write(path.join(globalDir, "workflows", "shared.yaml"), savedSpec("global-shared")), Bun.write(path.join(globalDir, "workflows", "global-only.yaml"), savedSpec("global-only")), + Bun.write( + path.join(globalDir, "workflows", "block-flow.yaml"), + "title: block flow title\nconfig:\n name: block-flow\n objective: Review a bounded change\n blocks:\n - id: decision\n kind: review\n", + ), Bun.write( path.join(workflowSpecDirectory, ".opencode", "workflows", "shared.yaml"), savedSpec("project-shared"), @@ -1476,6 +1559,7 @@ describe("workflow tool saved workflows", () => { expect(result.output).toContain("shared [project] — project-shared title") expect(result.output).toContain("global-only [global] — global-only title") + expect(result.output).toContain("block-flow [global] — block flow title (1 blocks)") expect(result.output).not.toContain("global-shared") }), ), diff --git a/packages/opencode/test/skill/skill.test.ts b/packages/opencode/test/skill/skill.test.ts index 456fd2afcd..755ebfefeb 100644 --- a/packages/opencode/test/skill/skill.test.ts +++ b/packages/opencode/test/skill/skill.test.ts @@ -83,11 +83,13 @@ describe("skill", () => { Effect.gen(function* () { const skill = yield* Skill.Service expect(yield* skill.get("workflow")).toBeUndefined() - expect((yield* skill.all()).filter((item) => item.location === "").map((item) => item.name)).toEqual([ - "customize-opencode", - "configure-hooks", - "create-dag-workflow", - ]) + expect( + (yield* skill.all()).filter((item) => item.location === "").map((item) => item.name), + ).toEqual(["customize-opencode", "configure-hooks", "create-dag-workflow", "orchestration-router"]) + expect(yield* skill.get("orchestration-router")).toMatchObject({ + description: expect.stringContaining("without waiting for /dag-flow"), + location: "", + }) }), { git: true }, ), From 54354fdb65f598f88e2cf9d940d92653964fbff6 Mon Sep 17 00:00:00 2001 From: lex Date: Mon, 10 Aug 2026 11:26:34 +0800 Subject: [PATCH 2/7] fix(dag): harden proactive workflow routing --- .../plugin/command/orchestration-policy.md | 16 +-- .../src/plugin/command/workflow-routing.md | 5 + packages/core/src/plugin/command/workflow.md | 28 +++-- .../src/plugin/skill/orchestration-router.md | 6 + packages/core/test/plugin/command.test.ts | 6 + packages/core/test/plugin/skill.test.ts | 2 + packages/opencode/src/dag/blocks.ts | 57 ++++++--- packages/opencode/src/session/tools.ts | 11 +- packages/opencode/src/tool/workflow.ts | 111 ++++++++---------- packages/opencode/test/dag/blocks.test.ts | 68 +++++++++-- .../test/dag/workflow-child-tools.test.ts | 109 +++++++++++++++++ .../opencode/test/dag/workflow-tool.test.ts | 65 +++++++++- 12 files changed, 365 insertions(+), 119 deletions(-) create mode 100644 packages/opencode/test/dag/workflow-child-tools.test.ts diff --git a/packages/core/src/plugin/command/orchestration-policy.md b/packages/core/src/plugin/command/orchestration-policy.md index ded227f7da..b51985896f 100644 --- a/packages/core/src/plugin/command/orchestration-policy.md +++ b/packages/core/src/plugin/command/orchestration-policy.md @@ -61,13 +61,15 @@ Choose the smallest child execution mode that can safely complete the request: 1. Use direct execution only for conversation, trivial state inspection, workflow control, final synthesis, or an explicit user opt-out. -2. Use one `task` subagent for one independent non-trivial leaf assignment when - no graph-level coordination is needed. The parent launches it once, consumes - its result, and does not duplicate the leaf work. -3. Use one live `workflow` DAG when one user objective contains staged - dependencies, two or more related workstreams, a quality gate, unknown-size - discovery, adaptive repair, or an explicit multi-role or multi-model - requirement. +2. Use one `task` subagent for one independent non-trivial leaf assignment + outside a project-level source or test change when no graph-level + coordination is needed. The parent launches it once, consumes its result, + and does not duplicate the leaf work. +3. Use one live `workflow` DAG for project-level source or test changes, even + when only one project file is expected, and whenever one user objective + contains staged dependencies, two or more related workstreams, a quality + gate, unknown-size discovery, adaptive repair, or an explicit multi-role or + multi-model requirement. "Smallest" is measured against the Depth Ladder: a mode or graph that cannot deliver the ladder's hard minimum for the target size is not safe, merely diff --git a/packages/core/src/plugin/command/workflow-routing.md b/packages/core/src/plugin/command/workflow-routing.md index cf9d7977e4..86032b3fc9 100644 --- a/packages/core/src/plugin/command/workflow-routing.md +++ b/packages/core/src/plugin/command/workflow-routing.md @@ -46,10 +46,15 @@ Load details only when needed: decision; it is not a waiting mechanism. - **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. Prefer high-level `blocks` for a fresh one-off flow. Use low-level `nodes` when the task needs custom bindings, conditions, output schemas, or review metadata. Never provide both. Reusable saved YAML remains valid and may use either form. +When a saved route is generic, call **read**, replace its objective and +block-specific instructions with the confirmed request, then pass the result +as an inline **start** spec. Start by `spec_path` only when the saved target +already matches exactly. The workflow runs asynchronously and wakes the parent at actionable reporting nodes or terminal state. Do not poll, sleep, or loop merely to wait. Never diff --git a/packages/core/src/plugin/command/workflow.md b/packages/core/src/plugin/command/workflow.md index 937a1b9f11..bfa260a205 100644 --- a/packages/core/src/plugin/command/workflow.md +++ b/packages/core/src/plugin/command/workflow.md @@ -11,20 +11,22 @@ Compile every graph under the Tiered Orchestration Doctrine and Depth Ladder in ## When to start a workflow -Use one live workflow when a user objective has any of these structural -signals: +Use one live workflow for project-level source or test changes, even when only +one project file is expected, and when a user objective has any of these +structural signals: - **Staged**: clear phase boundaries where later phases depend on earlier outputs (explore → plan → implement → verify). - **Parallelizable**: ≥2 related sub-units can execute concurrently (same fix across 5 packages). - **Quality gate**: intermediate output must pass review before downstream work begins (architecture review before implementation). - **Adaptive scope**: discovery may reveal an unknown number of work packages or require a bounded repair wave. -Use one `task` subagent for one independent non-trivial leaf assignment. Keep -related staged, parallel, gated, or adaptive flows under one workflow ID; use -`extend` or `control(replan)` instead of starting disconnected DAGs. An explicit -`/dag-flow` request always selects a workflow. Direct tools in the parent are -reserved for conversation, trivial state inspection, workflow control, and -final synthesis. +Use one `task` subagent for one independent non-trivial leaf assignment outside +a project-level source or test change. Keep related staged, parallel, gated, or +adaptive flows under one workflow ID; use `extend` or `control(replan)` instead +of starting disconnected DAGs. An explicit `/dag-flow` request always selects +a workflow. Explicit “single agent”, “do not use DAG”, and direct-execution +requests opt out. Direct tools in the parent are reserved for conversation, +trivial state inspection, workflow control, and final synthesis. ## Standard and deep workflow entry @@ -90,9 +92,9 @@ resolves nowhere fails with the directories that were searched. Prefer a saved workflow when the user names a recurring procedure ("run the code review workflow") and the saved target/inputs already match: starting it -is one call, and its graph has already been reviewed. `/dag-flow` may also read -a saved workflow as a topology reference, then derive a one-off spec that -injects the current task, retargets module lanes, and records additions/prunes. +is one call, and its graph has already been reviewed. When only its topology +matches, call `{ action: "read", spec_path: "code-review" }`, retarget its objective and block instructions to the current task, prune or add lanes, then +start that edited value as an inline spec. `read` never starts a workflow. Compose a fresh inline `spec` when the task is one-off or no reference fits. To turn a working one-off spec into a saved workflow, persist it as YAML in one of the two workflow-library directories under a descriptive name. @@ -549,6 +551,10 @@ execution order are computed automatically. scope) with their names, titles, and node counts. This lists reusable specs, not running workflows; use `status` for a workflow's live state. +**read** — Return one saved workflow as structured JSON without starting it. +Pass `spec_path`, then retarget generic objectives and block instructions in +the parent before using the edited result as an inline `start` spec. + **extend** — Add nodes to a running workflow. Existing nodes are unaffected; new nodes are immediately eligible for scheduling if their dependencies are met. It also accepts a genuinely additive wave after a reporting leaf diff --git a/packages/core/src/plugin/skill/orchestration-router.md b/packages/core/src/plugin/skill/orchestration-router.md index 40e63561e8..9d264918a5 100644 --- a/packages/core/src/plugin/skill/orchestration-router.md +++ b/packages/core/src/plugin/skill/orchestration-router.md @@ -69,6 +69,12 @@ not already in context. Select only justified blocks: - runnable uncertainty: prototype detour → update the plan; - review-only: scope exploration → independent review and arbitration. +When a reusable route matches the topology, call +`workflow(action="read", spec_path="")`, retarget the objective and +block instructions to the confirmed request, prune unjustified blocks, and +start the edited result as an inline spec. Start the saved `spec_path` directly +only when its target already matches exactly. + Use a skill name on a block only when it appears in the available skill catalog. Test-first implementation and standards/spec review belong in their respective coding and review blocks, not in the always-on router prompt. diff --git a/packages/core/test/plugin/command.test.ts b/packages/core/test/plugin/command.test.ts index 9dfdd88e8e..e4213fce3d 100644 --- a/packages/core/test/plugin/command.test.ts +++ b/packages/core/test/plugin/command.test.ts @@ -63,6 +63,8 @@ describe("CommandPlugin.Plugin", () => { expect(CommandPlugin.WorkflowContent).toContain("Use direct execution for") expect(CommandPlugin.WorkflowContent).toContain("one `task` subagent") expect(CommandPlugin.WorkflowContent).toContain("Related work for one objective") + expect(CommandPlugin.WorkflowFactsContent).toContain("project-level source or test changes") + expect(CommandPlugin.WorkflowFactsContent).toMatch(/even when only\s+one project file/) expect(CommandPlugin.WorkflowFactsContent).not.toContain("when ANY") expect(CommandPlugin.WorkflowFactsContent).not.toContain("- **Multi-model**:") expect(CommandPlugin.DagFlowContent).toContain('workflow(action="start")') @@ -90,6 +92,7 @@ describe("CommandPlugin.Plugin", () => { expect(CommandPlugin.OrchestrationPolicyContent).toContain("MUST NOT perform executable leaf work") expect(CommandPlugin.OrchestrationPolicyContent).toContain("one `task` subagent") expect(CommandPlugin.OrchestrationPolicyContent).toContain("one live `workflow` DAG") + expect(CommandPlugin.OrchestrationPolicyContent).toContain("outside a project-level source or test change") expect(CommandPlugin.OrchestrationPolicyContent).toContain("one user objective") expect(CommandPlugin.DagFlowContent).toMatch(/one consolidated\s+graph/) }), @@ -99,6 +102,9 @@ describe("CommandPlugin.Plugin", () => { Effect.sync(() => { expect(CommandPlugin.WorkflowFactsContent).toContain("For a one-off graph, pass `spec` inline") expect(CommandPlugin.WorkflowFactsContent).toContain("Use `spec_path` only") + expect(CommandPlugin.WorkflowContent).toContain("**read**") + expect(CommandPlugin.WorkflowFactsContent).toContain('{ action: "read", spec_path: "code-review" }') + expect(CommandPlugin.WorkflowFactsContent).toContain("retarget its objective and block instructions") expect(CommandPlugin.WorkflowFactsContent).not.toContain("Never inline graph nodes") expect(CommandPlugin.WorkflowFactsContent).not.toContain("Before any graph-carrying action") expect(CommandPlugin.DagFlowContent).toContain("inline `spec`") diff --git a/packages/core/test/plugin/skill.test.ts b/packages/core/test/plugin/skill.test.ts index baadb4b492..c032e70dc3 100644 --- a/packages/core/test/plugin/skill.test.ts +++ b/packages/core/test/plugin/skill.test.ts @@ -75,6 +75,8 @@ describe("SkillPlugin.Plugin", () => { const router = (yield* skill.list()).find((item) => item.name === "orchestration-router") expect(router?.description).toContain("even one project file") expect(router?.description).toContain("isolated utility scripts") + expect(router?.content).toContain('workflow(action="read"') + expect(router?.content).toContain("retarget the objective") }), ) diff --git a/packages/opencode/src/dag/blocks.ts b/packages/opencode/src/dag/blocks.ts index 0309a28f18..ef93a76c52 100644 --- a/packages/opencode/src/dag/blocks.ts +++ b/packages/opencode/src/dag/blocks.ts @@ -1,3 +1,4 @@ +import { Schema } from "effect" import type { NodeConfig } from "./dag" export const WORKFLOW_BLOCK_KINDS = [ @@ -13,16 +14,32 @@ export const WORKFLOW_BLOCK_KINDS = [ export type WorkflowBlockKind = (typeof WORKFLOW_BLOCK_KINDS)[number] -export interface WorkflowBlock { - id: string - kind: WorkflowBlockKind - depends_on?: string[] - instruction?: string - skills?: string[] - worker_type?: string - required?: boolean - report_to_parent?: boolean -} +export const WorkflowBlock = Schema.Struct({ + 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", + }), + depends_on: Schema.optional(Schema.Array(Schema.String)).annotate({ + description: "Block IDs this block waits for. Defaults to []", + }), + instruction: Schema.optional(Schema.String).annotate({ + description: "Task-specific instruction added to the block's built-in execution contract", + }), + skills: Schema.optional(Schema.Array(Schema.String)).annotate({ + description: "Relevant skills the child should load lazily before working", + }), + worker_type: Schema.optional(Schema.String).annotate({ + description: "Optional configured agent override; defaults from the block kind", + }), + required: Schema.optional(Schema.Boolean).annotate({ + description: + "Whether failure is terminal. Decision and verification blocks default to true; volume blocks to false", + }), + 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 @@ -112,7 +129,7 @@ export function compileWorkflowBlocks( function compileBlock(objective: string, block: WorkflowBlock, blocks: WorkflowBlock[]): NodeConfig[] { const dependencies = block.depends_on ?? [] - const required = block.required ?? true + const required = block.required ?? (block.kind === "plan" || block.kind === "verify" || block.kind === "synthesize") const reviewDependency = dependencies.find( (dependency) => blocks.find((candidate) => candidate.id === dependency)?.kind === "review", ) @@ -131,7 +148,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, + required: block.required ?? false, reportToParent: false, condition, }), @@ -144,7 +161,7 @@ function compileBlock(objective: string, block: WorkflowBlock, blocks: WorkflowB instruction: block.instruction, skills: block.skills, contract: BLOCK_CONTRACTS.debug, - required, + required: block.required ?? true, reportToParent: block.report_to_parent ?? false, }), ] @@ -163,7 +180,7 @@ 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, + required: block.required ?? false, reportToParent: false, condition, }), @@ -176,7 +193,7 @@ 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, + required: block.required ?? false, reportToParent: false, condition, }), @@ -193,7 +210,7 @@ function compileBlock(objective: string, block: WorkflowBlock, blocks: WorkflowB "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, + required: block.required ?? true, reportToParent: block.report_to_parent ?? true, outputSchema: VERDICT_SCHEMA, }), @@ -221,10 +238,10 @@ function node(input: { id: string name: string workerType: string - dependencies: string[] + dependencies: readonly string[] objective: string instruction?: string - skills?: string[] + skills?: readonly string[] contract: string required: boolean reportToParent: boolean @@ -239,7 +256,7 @@ function node(input: { id: input.id, name: input.name, worker_type: input.workerType, - depends_on: input.dependencies, + depends_on: [...input.dependencies], required: input.required, report_to_parent: input.reportToParent, prompt_template: { @@ -292,3 +309,5 @@ function assertAcyclic(blocks: WorkflowBlock[]) { } } } + +export * as DagBlocks from "./blocks" diff --git a/packages/opencode/src/session/tools.ts b/packages/opencode/src/session/tools.ts index 4f31a4fc79..252afaf725 100644 --- a/packages/opencode/src/session/tools.ts +++ b/packages/opencode/src/session/tools.ts @@ -43,6 +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"]) export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { agent: Agent.Info @@ -100,13 +101,13 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { providerID: input.model.providerID, agent: input.agent, })) { + if (input.session.parentID && ROOT_ONLY_TOOLS.has(item.id)) continue if ( item.id === MemorySearch.MemorySearchTool.id && - (input.session.parentID || - Permission.disabled( - [MemorySearch.MemorySearchTool.id], - Permission.merge(input.agent.permission, input.session.permission ?? []), - ).has(MemorySearch.MemorySearchTool.id)) + Permission.disabled( + [MemorySearch.MemorySearchTool.id], + Permission.merge(input.agent.permission, input.session.permission ?? []), + ).has(MemorySearch.MemorySearchTool.id) ) continue const schema = ProviderTransform.schema(input.model, ToolJsonSchema.fromTool(item)) diff --git a/packages/opencode/src/tool/workflow.ts b/packages/opencode/src/tool/workflow.ts index 85dfae898b..47485624fa 100644 --- a/packages/opencode/src/tool/workflow.ts +++ b/packages/opencode/src/tool/workflow.ts @@ -5,7 +5,7 @@ import { Dag } from "@/dag/dag" import { DagConfig } from "@/dag/config" import { DagWorkflows } from "@/dag/workflows" import { DagModel } from "@/dag/model" -import { compileWorkflowBlocks, WORKFLOW_BLOCK_KINDS, type WorkflowBlock } from "@/dag/blocks" +import { DagBlocks } from "@/dag/blocks" import { Agent } from "@/agent/agent" import { Question } from "@/question" import { Session } from "@/session/session" @@ -77,31 +77,6 @@ const NodeSchema = Schema.Struct({ }), }) -const BlockSchema = Schema.Struct({ - 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", - }), - depends_on: Schema.optional(Schema.Array(Schema.String)).annotate({ - description: "Block IDs this block waits for. Defaults to []", - }), - instruction: Schema.optional(Schema.String).annotate({ - description: "Task-specific instruction added to the block's built-in execution contract", - }), - skills: Schema.optional(Schema.Array(Schema.String)).annotate({ - description: "Relevant skills the child should load lazily before working", - }), - worker_type: Schema.optional(Schema.String).annotate({ - description: "Optional configured agent override; defaults from the block kind", - }), - required: Schema.optional(Schema.Boolean).annotate({ - description: "Whether execution failure is terminal. Defaults to true for compiled blocks", - }), - report_to_parent: Schema.optional(Schema.Boolean).annotate({ - description: "Override wake behavior. Review decisions and synthesis report by default", - }), -}) - const WorkflowGraphSchema = Schema.Struct({ name: Schema.String.annotate({ description: "Workflow name" }), node_defaults: Schema.optional( @@ -127,7 +102,7 @@ const WorkflowGraphSchema = Schema.Struct({ objective: Schema.optional(Schema.String).annotate({ description: "Required when using blocks; injected into every generated child prompt", }), - blocks: Schema.optional(Schema.Array(BlockSchema)).annotate({ + blocks: Schema.optional(Schema.Array(DagBlocks.WorkflowBlock)).annotate({ description: "High-level graph compiled into nodes. Use blocks or nodes, never both", }), nodes: Schema.optional(Schema.Array(NodeSchema)).annotate({ @@ -145,7 +120,7 @@ export const StartSpec = Schema.Struct({ const ExtendSpec = Schema.Struct({ objective: Schema.optional(Schema.String), - blocks: Schema.optional(Schema.Array(BlockSchema)), + blocks: Schema.optional(Schema.Array(DagBlocks.WorkflowBlock)), nodes: Schema.optional(Schema.Array(NodeSchema)), }) @@ -158,9 +133,9 @@ const decodeExtendSpec = Schema.decodeUnknownEffect(ExtendSpec) const decodeReplanSpec = Schema.decodeUnknownEffect(ReplanSpec) export const Parameters = Schema.Struct({ - action: Schema.Literals(["start", "extend", "control", "status", "list", "guide"]).annotate({ + action: Schema.Literals(["start", "extend", "control", "status", "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; 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; 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: @@ -172,7 +147,7 @@ export const Parameters = Schema.Struct({ }), spec_path: Schema.optional(Schema.String).annotate({ description: - '(start/extend/control replan) A saved workflow name from the library (e.g. "code-review"), or a path to a YAML workflow spec. Relative paths resolve from the session directory', + '(start/extend/control replan/read) A saved workflow name from the library (e.g. "code-review"), or a path to a YAML workflow spec. Relative paths resolve from the session directory', }), session_id: Schema.optional(Schema.String).annotate({ description: "(start) Parent session ID; when provided, it must match the calling session", @@ -220,6 +195,12 @@ export const WorkflowTool = Tool.define< parameters: Parameters, execute: (params: Schema.Schema.Type, ctx: Tool.Context) => Effect.gen(function* () { + const callingSession = yield* sessions.get(SessionID.make(ctx.sessionID)).pipe(Effect.orDie) + if (callingSession.parentID) { + return yield* Effect.die( + new Error("Workflow orchestration is available only to the main conversation, not child agents"), + ) + } switch (params.action) { case "guide": { if (!params.topic) { @@ -276,6 +257,22 @@ export const WorkflowTool = Tool.define< metadata: {}, } } + case "read": { + if (!params.spec_path || params.spec) { + return yield* Effect.die( + new Error("read requires exactly one 'spec_path' and does not accept inline 'spec'"), + ) + } + const session = yield* sessions.get(SessionID.make(ctx.sessionID)).pipe(Effect.orDie) + const specFile = yield* readWorkflowSpec(undefined, params.spec_path, session.directory, ctx).pipe( + Effect.orDie, + ) + return { + title: `Workflow spec: ${params.spec_path}`, + output: JSON.stringify(specFile.value, null, 2), + metadata: {}, + } + } case "status": { if (!params.workflow_id) return yield* Effect.die(new Error("status requires 'workflow_id'")) const workflow = yield* requireOwnedWorkflow(params.workflow_id, ctx.sessionID) @@ -340,7 +337,7 @@ export const WorkflowTool = Tool.define< Effect.mapError((error) => new Error(`Invalid workflow spec ${specFile.path}: ${String(error)}`)), Effect.orDie, ) - const config = yield* compileGraph(spec.config, specFile.path).pipe(Effect.orDie) + const config = compileGraph(spec.config, specFile.path) const missingModels = yield* findNodesWithoutModel({ nodes: config.nodes, defaults: config.node_defaults, @@ -411,7 +408,7 @@ export const WorkflowTool = Tool.define< const knownDependencies = (yield* dag.store.getNodes(params.workflow_id).pipe(Effect.orDie)).map( (node) => node.id, ) - const nodes = yield* compileNodeSource(spec, specFile.path, knownDependencies).pipe(Effect.orDie) + const nodes = compileNodeSource(spec, specFile.path, knownDependencies) const r = yield* withTerminalRecovery( dag.extend(params.workflow_id, nodes), "Terminal workflows are immutable except for the additive-extend reopen, which requires the workflow to have completed naturally at a wake-eligible reporting checkpoint (fragment adds new node ids; no early control(complete); no executed node beyond the checkpoint — condition-skipped dependents are fine). When the reopen does not apply, recover by starting a NEW workflow spec that reuses this workflow's completed outputs as static input.", @@ -471,9 +468,7 @@ export const WorkflowTool = Tool.define< Effect.orDie, ) const knownDependencies = (yield* dag.store.getNodes(wfId).pipe(Effect.orDie)).map((node) => node.id) - const fragment = yield* compileGraph(spec.fragment, specFile.path, knownDependencies).pipe( - Effect.orDie, - ) + const fragment = compileGraph(spec.fragment, specFile.path, knownDependencies) // The graph raced to terminal while the fragment was being // composed (the pause-first protocol was skipped). Surface // the recovery options instead of a bare iron-law rejection. @@ -518,37 +513,27 @@ type WorkflowGraphInput = Schema.Schema.Type type NodeSource = Pick function compileGraph(graph: WorkflowGraphInput, source: string, knownDependencies?: string[]) { - return Effect.gen(function* () { - const nodes = yield* compileNodeSource(graph, source, knownDependencies) - const { objective: _objective, blocks: _blocks, nodes: _nodes, ...config } = graph - return { - ...config, - nodes, - } as WorkflowConfig - }) + const nodes = compileNodeSource(graph, source, knownDependencies) + const { objective: _objective, blocks: _blocks, nodes: _nodes, ...config } = graph + return { + ...config, + nodes, + } as WorkflowConfig } function compileNodeSource(source: NodeSource, path: string, knownDependencies?: string[]) { - return Effect.try({ - try: () => { - const hasNodes = source.nodes !== undefined - const hasBlocks = source.blocks !== undefined - if (hasNodes === hasBlocks) { - throw new Error("use exactly one of nodes or blocks") - } - if (source.nodes) return source.nodes as NodeConfig[] - if (!source.objective) throw new Error("blocks require objective") - return compileWorkflowBlocks( - { - objective: source.objective, - blocks: source.blocks as WorkflowBlock[], - }, - { known_dependencies: knownDependencies }, - ) + const hasNodes = source.nodes !== undefined + const hasBlocks = source.blocks !== undefined + if (hasNodes === hasBlocks) throw new Error(`Invalid workflow graph ${path}: use exactly one of nodes or blocks`) + if (source.nodes) return source.nodes as NodeConfig[] + if (!source.objective) throw new Error(`Invalid workflow graph ${path}: blocks require objective`) + return DagBlocks.compileWorkflowBlocks( + { + objective: source.objective, + blocks: source.blocks as DagBlocks.WorkflowBlock[], }, - catch: (error) => - new Error(`Invalid workflow graph ${path}: ${error instanceof Error ? error.message : String(error)}`), - }) + { known_dependencies: knownDependencies }, + ) } function readWorkflowSpec( diff --git a/packages/opencode/test/dag/blocks.test.ts b/packages/opencode/test/dag/blocks.test.ts index bdff18566b..0089347112 100644 --- a/packages/opencode/test/dag/blocks.test.ts +++ b/packages/opencode/test/dag/blocks.test.ts @@ -1,9 +1,10 @@ import { describe, expect, it } from "bun:test" -import { compileWorkflowBlocks } from "@/dag/blocks" +import { DagBlocks } from "@/dag/blocks" +import { DagConfig } from "@/dag/config" describe("workflow blocks", () => { it("compiles a staged route and carries objective, instructions, skills, and dependencies", () => { - const nodes = compileWorkflowBlocks({ + const nodes = DagBlocks.compileWorkflowBlocks({ objective: "Add durable session recovery", blocks: [ { @@ -36,11 +37,15 @@ describe("workflow blocks", () => { }) expect(nodes[1]?.prompt_template.inline).toContain("load these relevant skills") expect(nodes[1]?.prompt_template.inline).toContain("tdd") - expect(nodes.every((node) => node.required)).toBe(true) + expect(nodes.map((node) => ({ id: node.id, required: node.required }))).toEqual([ + { id: "map", required: false }, + { id: "build", required: false }, + { id: "verify", required: true }, + ]) }) it("expands debug into evidence and diagnosis nodes", () => { - const nodes = compileWorkflowBlocks({ + const nodes = DagBlocks.compileWorkflowBlocks({ objective: "Find the source of a timeout", blocks: [{ id: "root-cause", kind: "debug", report_to_parent: true }], }) @@ -59,7 +64,7 @@ describe("workflow blocks", () => { }) it("expands review into two independent lanes and a reporting verdict gate", () => { - const nodes = compileWorkflowBlocks({ + const nodes = DagBlocks.compileWorkflowBlocks({ objective: "Review the implementation", blocks: [ { id: "implementation", kind: "coding" }, @@ -89,16 +94,55 @@ describe("workflow blocks", () => { expect(nodes.find((node) => node.id === "report")?.condition).toBe('decision.output.verdict == "ACCEPT"') }) + 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", + blocks: [ + { id: "map", kind: "explore" }, + { id: "plan", kind: "plan", depends_on: ["map"] }, + { id: "experiment", kind: "prototype", depends_on: ["map"] }, + { id: "diagnose", kind: "debug", depends_on: ["map"] }, + { id: "build", kind: "coding", depends_on: ["plan", "diagnose"] }, + { id: "verify", kind: "verify", depends_on: ["build", "experiment"] }, + { id: "decision", kind: "review", depends_on: ["verify"] }, + { id: "report", kind: "synthesize", depends_on: ["decision"] }, + ], + }) + const models = Object.fromEntries( + nodes.map((node) => [ + node.id, + DagConfig.tierModel( + { model: { advanced: "test/advanced", standard: "test/standard" } }, + { required: node.required ?? false, workerType: node.worker_type }, + )?.modelID, + ]), + ) + + expect(models).toEqual({ + map: "standard", + plan: "advanced", + experiment: "standard", + "diagnose--evidence": "standard", + diagnose: "advanced", + build: "standard", + verify: "advanced", + "decision--standards": "standard", + "decision--intent": "standard", + decision: "advanced", + report: "advanced", + }) + }) + it("rejects ambiguous dependencies and expansion collisions", () => { expect(() => - compileWorkflowBlocks({ + DagBlocks.compileWorkflowBlocks({ objective: "Invalid graph", blocks: [{ id: "build", kind: "coding", depends_on: ["missing"] }], }), ).toThrow('Block "build" depends on unknown block "missing"') expect(() => - compileWorkflowBlocks({ + DagBlocks.compileWorkflowBlocks({ objective: "Colliding graph", blocks: [ { id: "check", kind: "review" }, @@ -108,7 +152,7 @@ describe("workflow blocks", () => { ).toThrow("Block expansion creates duplicate node ids: check--intent") expect(() => - compileWorkflowBlocks({ + DagBlocks.compileWorkflowBlocks({ objective: "Cyclic graph", blocks: [ { id: "a", kind: "plan", depends_on: ["b"] }, @@ -118,7 +162,7 @@ describe("workflow blocks", () => { ).toThrow("dependency cycle") expect(() => - compileWorkflowBlocks({ + DagBlocks.compileWorkflowBlocks({ objective: "Unsafe ID", blocks: [{ id: "review.output", kind: "review" }], }), @@ -126,7 +170,7 @@ describe("workflow blocks", () => { }) it("allows an extension block to depend on an existing durable node", () => { - const nodes = compileWorkflowBlocks( + const nodes = DagBlocks.compileWorkflowBlocks( { objective: "Continue from durable evidence", blocks: [{ id: "repair", kind: "coding", depends_on: ["existing-evidence"] }], @@ -138,11 +182,11 @@ describe("workflow blocks", () => { }) it("requires one objective and one review dependency per continuation", () => { - expect(() => compileWorkflowBlocks({ objective: " ", blocks: [{ id: "x", kind: "coding" }] })).toThrow( + expect(() => DagBlocks.compileWorkflowBlocks({ objective: " ", blocks: [{ id: "x", kind: "coding" }] })).toThrow( "non-empty objective", ) expect(() => - compileWorkflowBlocks({ + DagBlocks.compileWorkflowBlocks({ objective: "Ambiguous gates", blocks: [ { id: "review-a", kind: "review" }, diff --git a/packages/opencode/test/dag/workflow-child-tools.test.ts b/packages/opencode/test/dag/workflow-child-tools.test.ts new file mode 100644 index 0000000000..48f2278d88 --- /dev/null +++ b/packages/opencode/test/dag/workflow-child-tools.test.ts @@ -0,0 +1,109 @@ +import { describe, expect } from "bun:test" +import { ModelV2 } from "@opencode-ai/core/model" +import { ProjectV2 } from "@opencode-ai/core/project" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { Effect, Layer, Schema } from "effect" +import { Agent } from "@/agent/agent" +import { MCP } from "@/mcp" +import { Permission } from "@/permission" +import { Plugin } from "@/plugin" +import { MessageID, SessionID } from "@/session/schema" +import { SessionProcessor } from "@/session/processor" +import { Session } from "@/session/session" +import { SessionTools } from "@/session/tools" +import { Tool } from "@/tool/tool" +import { ToolRegistry } from "@/tool/registry" +import type { TaskPromptOps } from "@/tool/task" +import { Truncate } from "@/tool/truncate" +import { testEffect } from "../lib/effect" +import { ProviderTest } from "../fake/provider" + +const Parameters = Schema.Struct({}) +const workflowDefinition: Tool.Def = { + id: "workflow", + description: "workflow", + parameters: Parameters, + execute: () => Effect.succeed({ title: "workflow", output: "started", metadata: {} }), +} +const trigger: Plugin.Interface["trigger"] = (_name, _input, output) => Effect.succeed(output) +const it = testEffect( + Layer.mergeAll( + Agent.defaultLayer, + Truncate.defaultLayer, + Layer.mock(Plugin.Service, { + init: () => Effect.void, + list: () => Effect.succeed([]), + trigger, + }), + 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]) }), + ), +) + +describe("workflow child boundary", () => { + it.instance("exposes workflow 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") + + expect(yield* resolvedToolIDs(build, session(parentID))).toContain("workflow") + expect(yield* resolvedToolIDs(build, session(SessionID.make("ses_workflow_tool_child"), parentID))).not.toContain( + "workflow", + ) + }), + ) +}) + +function resolvedToolIDs(agent: Agent.Info, info: Session.Info) { + const userID = MessageID.make(`msg_${info.id}`) + const processor: Pick = { + message: { + id: MessageID.make(`msg_assistant_${info.id}`), + role: "assistant", + sessionID: info.id, + parentID: userID, + mode: agent.name, + agent: agent.name, + path: { cwd: info.directory, root: info.directory }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + modelID: ModelV2.ID.make("test-model"), + providerID: ProviderV2.ID.make("test"), + time: { created: 1 }, + }, + updateToolCall: () => Effect.void.pipe(Effect.as(undefined)), + completeToolCall: () => Effect.void, + } + const promptOps: TaskPromptOps = { + cancel: () => Effect.void, + resolvePromptParts: () => Effect.succeed([]), + prompt: () => Effect.die(new Error("prompt should not run while resolving tools")), + } + return SessionTools.resolve({ + agent, + model: ProviderTest.model({ + providerID: ProviderV2.ID.make("test"), + id: ModelV2.ID.make("test-model"), + }), + session: info, + processor, + bypassAgentCheck: false, + messages: [], + promptOps, + }).pipe(Effect.map((tools) => Object.keys(tools))) +} + +function session(id: SessionID, parentID?: SessionID): Session.Info { + return { + id, + slug: "workflow-child-tools", + projectID: ProjectV2.ID.global, + directory: "/tmp/opencode", + parentID, + title: "Workflow child tools", + version: "1.0.0", + time: { created: 1, updated: 1 }, + } +} diff --git a/packages/opencode/test/dag/workflow-tool.test.ts b/packages/opencode/test/dag/workflow-tool.test.ts index 5a37b50d6b..75910a66aa 100644 --- a/packages/opencode/test/dag/workflow-tool.test.ts +++ b/packages/opencode/test/dag/workflow-tool.test.ts @@ -302,6 +302,8 @@ const runtime = testEffect( slug: "workflow-test", projectID, directory: workflowSpecDirectory, + parentID: + id === SessionID.make("ses_workflow_child") ? SessionID.make("ses_workflow_parent") : undefined, title: "Workflow test", version: "test", time: { created: 0, updated: 0 }, @@ -373,7 +375,7 @@ function toolContext() { } describe("workflow tool schema (negative tests)", () => { - it("action field accepts start/extend/control/status/list/guide", () => { + it("action field accepts start/extend/control/status/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() @@ -381,6 +383,7 @@ describe("workflow tool schema (negative tests)", () => { expect(() => decode({ action: "status", workflow_id: "wf-1" })).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() expect(() => decode({ action: "guide", topic: "blocks" })).not.toThrow() }) @@ -445,12 +448,28 @@ describe("workflow tool schema (negative tests)", () => { }) describe("workflow tool execution", () => { + runtime.effect("rejects workflow orchestration from a child session even if invoked directly", () => + Effect.gen(function* () { + 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) + + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) expect(Cause.pretty(exit.cause)).toContain("main conversation") + expect(published).toHaveLength(0) + }), + ) + runtime.effect("description retains the workflow action reference after guidance migration", () => Effect.gen(function* () { const info = yield* WorkflowTool const workflow = yield* info.init() - for (const action of ["guide", "start", "extend", "status", "control"]) { + for (const action of ["guide", "start", "extend", "status", "control", "list", "read"]) { expect(workflow.description).toContain(`**${action}**`) } expect(workflow.description).toContain("Do not poll") @@ -1469,6 +1488,48 @@ describe("workflow tool saved workflows", () => { }), }) satisfies Tool.Context + runtime.effect("read returns a saved route for parent retargeting without starting it", () => + withGlobalConfigDir(() => + Effect.gen(function* () { + published.length = 0 + yield* Effect.promise(() => + Bun.write( + path.join(workflowSpecDirectory, ".opencode", "workflows", "saved-readable.yaml"), + [ + "title: Saved readable route", + "config:", + " name: saved-readable", + " objective: Replace this generic objective", + " blocks:", + " - id: map", + " kind: explore", + "", + ].join("\n"), + ), + ) + const info = yield* WorkflowTool + const workflow = yield* info.init() + const asked: unknown[] = [] + + 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({ + title: "Saved readable route", + config: { + objective: "Replace this generic objective", + blocks: [{ id: "map", kind: "explore" }], + }, + }) + expect(asked).toHaveLength(0) + expect(published).toHaveLength(0) + }), + ), + ) + runtime.effect("start resolves a bare name against the project workflow library", () => withGlobalConfigDir(() => Effect.gen(function* () { From 28df8bfa176ac71aca67d1c96b5fc59dd6fd116f Mon Sep 17 00:00:00 2001 From: lex Date: Mon, 10 Aug 2026 11:53:36 +0800 Subject: [PATCH 3/7] test(dag): align command routing contracts --- packages/core/src/plugin/command/dag-flow.txt | 2 +- packages/opencode/test/command/command.test.ts | 18 +++++++++--------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/core/src/plugin/command/dag-flow.txt b/packages/core/src/plugin/command/dag-flow.txt index d5edd9bd41..301efd7460 100644 --- a/packages/core/src/plugin/command/dag-flow.txt +++ b/packages/core/src/plugin/command/dag-flow.txt @@ -4,7 +4,7 @@ $ARGUMENTS -If the task is empty, ask for it and do not start a workflow. Otherwise load +If the task is empty or contains only whitespace, ask for it and do not start a workflow. Otherwise load the `orchestration-router` skill and route the request through one consolidated graph. `/dag-flow` explicitly selects DAG execution, but it does not waive a material user decision. diff --git a/packages/opencode/test/command/command.test.ts b/packages/opencode/test/command/command.test.ts index c0998f6e94..160937d0ac 100644 --- a/packages/opencode/test/command/command.test.ts +++ b/packages/opencode/test/command/command.test.ts @@ -97,23 +97,23 @@ describe("legacy command registry", () => { const expanded = SessionPrompt.expandCommandTemplate(CommandPlugin.DagFlowContent, "Run two parallel workers") expect(expanded).toContain("Do not poll") - expect(expanded).toContain("End the current response") + expect(expanded).toContain("and end the response") }), ) - it.effect("requires profile-aware compilation without dropping task constraints", () => + it.effect("requires router-driven compilation without dropping task constraints", () => Effect.sync(() => { const expanded = SessionPrompt.expandCommandTemplate( CommandPlugin.DagFlowContent, "Use @security-reviewer to review this project. Do not modify files.", ) - expect(expanded).toContain("classify the task as `brainstorm`, `review`, or `develop`") - expect(expanded).toContain("preserve every user constraint") - expect(expanded).toContain("eligible configured worker types") - expect(expanded).toContain("Do not invent a missing role or model") - expect(expanded).toContain("actual error") - expect(expanded).toContain("must actually contain the requested synthesis") + expect(expanded).toContain("orchestration-router") + expect(expanded).toMatch(/Preserve\s+the task, user constraints/) + expect(expanded).toContain("worker types or model IDs") + expect(expanded).toContain("configured capability or model") + expect(expanded).toContain("real error") + expect(expanded).toContain("final synthesis block must contain the requested result") }), ) @@ -123,7 +123,7 @@ describe("legacy command registry", () => { expect(expanded).toContain("\n \n") expect(expanded).toContain("empty or contains only whitespace") - expect(expanded).toContain("Do not call the `workflow` tool") + expect(expanded).toContain("do not start a workflow") }), ) }) From 4a13abc500d7c0f61acbc62edee975442994e4e5 Mon Sep 17 00:00:00 2001 From: lex Date: Mon, 10 Aug 2026 13:57:06 +0800 Subject: [PATCH 4/7] 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 5/7] 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 6/7] 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 7/7] 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,