fix(opencode): implement reformatOutput so structured-output agents recover - #803
Conversation
…ecover The reformatOutput fallback was a stub that always threw "SDK not implemented", so when an opencode agent (e.g. the planner) emitted non-JSON output — it spends its turn on tool-calls instead of emitting the final JSON block — primary extraction failed and the cluster died with "Agent ... output missing required JSON block" (becoming a zombie). Trivial tasks skip the planner, so only complex tasks were affected. Implement reformatOutput using the opencode CLI as the reformatting backend: - Add a strict "do not use tools / do not read files" instruction to the reformat prompt; otherwise opencode re-enters tool-use on filenames mentioned in the raw output and hangs (>120s vs ~15s). - Spawn the CLI with stdin ignored (stdio:['ignore','pipe','pipe']); execFile leaves stdin as an open pipe and opencode blocks waiting for stdin EOF. Verified: unit test (reformat yields schema-valid JSON in ~14s) and end-to-end (a complex opencode task now passes the planner and progresses instead of becoming a zombie).
Greptile SummaryThis PR implements OpenCode structured-output recovery and adds lifecycle-safe nested execution support.
Confidence Score: 3/5The PR is not yet safe to merge because structured-output recovery still fails for configured providers other than OpenCode. The shared parser invokes the fallback for every structured-output provider, but the fallback explicitly rejects non-OpenCode providers and consequently reaches the same missing-required-JSON terminal failure. Files Needing Attention: src/agent/output-reformatter.js and src/agent/agent-task-executor.js
|
| Filename | Overview |
|---|---|
| src/agent/output-reformatter.js | Implements retryable OpenCode-only schema reformatting with cancellation-aware error propagation. |
| src/agent/agent-task-executor.js | Adds nested task spawning, formatter tool boundaries, structured-result caching, and normal/isolated cancellation integration. |
| src/agent/task-execution-handle.js | Introduces independent nested execution ownership, deadlines, cancellation settlement, and fail-closed retention. |
| src/agent/agent-lifecycle.js | Extends shutdown and liveness recovery to include active nested executions. |
| task-lib/runner.js | Forwards the generated OpenCode formatter-agent identity through the provider command boundary. |
| tests/output-reformatter.test.js | Covers OpenCode reformat success, validation retries, cancellation, and the explicit rejection of other providers. |
| tests/task-execution-lifecycle.test.js | Exercises nested execution cancellation, timeout, ownership retention, and lifecycle propagation. |
Flowchart
%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[Provider task completes] --> B{Structured JSON extracted?}
B -->|Yes| C[Validate and cache parsed result]
B -->|No, OpenCode schema task| D[Register nested execution handle]
D --> E[Launch tool-disabled formatter agent]
E --> F{Formatter returns valid JSON?}
F -->|Yes| C
F -->|No| G[Retry within configured attempt limit]
G --> E
D --> H[Stop, timeout, or liveness cancellation]
H --> I[Terminate nested durable task]
I --> J{Termination confirmed?}
J -->|Yes| K[Settle and unregister handle]
J -->|No| L[Retain ownership and fail closed]
C --> M[Hooks and result substitution reuse cache]
Reviews (22): Last reviewed commit: "Merge remote-tracking branch 'origin/mai..." | Re-trigger Greptile
tomdps
left a comment
There was a problem hiding this comment.
The reported root cause is real: the previous reformatOutput path always rejected after structured extraction failed, so tool-only OpenCode output fell through to Agent ... output missing required JSON block.
I approved and ran the external-fork workflows, preserved PaysNatal's original commit/authorship, and had two independent review passes over the updated branch. The current head is not safe to merge. Maintainer follow-up commits exposed a structural lifecycle gap that cannot be closed by snapshotting the existing singleton task fields.
Required changes:
-
Create a first-class reentrant task-execution handle before launch.
src/agent/agent-task-executor.js:881-887starts the local wrapper before an owned ID/handle exists;:932-937,:671-703, and:1455assign the ID and follower later. The isolated path has the same gap at:1573,:1620, and:2085. Cancellation or setup failure in those windows can orphan a provider task, and a retry can overlap it. The handle must own local and isolated launches before process creation, accept cancellation before the task ID exists, clean up a task whose ID arrives late, terminate the owned process tree, and await settlement before retry/rethrow. -
Keep parent and formatter identities separate. The nested launch at
src/agent/agent-task-executor.js:2228-2229currently reusesAgentWrapper.currentTask/currentTaskId/processPid. That overwrites the parent ID used byTASK_COMPLETED,TOKEN_USAGE, hooks, resume, and diagnostics. Use immutable parent/child identities on the execution handle; do not snapshot/restore shared globals. -
Reuse the validated recovery object.
src/agent/agent-task-executor.js:1213-1216currently returns the original unparsable output after recovery. Completion hooks then parse/reformat a second time, adding another model call and potentially publishing a different result. Carry the validated parsed object through local and isolated completion and make{{result.*}}substitution consume that cached object. -
Preserve cancellation identity end to end.
REFORMAT_CANCELLEDmust remain cancellation throughparseResultOutput; it must never become a missing-JSON error.
Focused regression coverage must include: local and isolated cancellation during pre-ID registration; failure after task creation but before follower installation; late-ID cleanup; no retry before tree settlement; one recovery model call across validation and hooks; explicit-model/isolation provenance; and parent task IDs retained in completion/token/hook metadata.
The current AgentWrapper has only one task ownership slot, and IsolationManager has no atomic nested-launch handle, so this needs the lifecycle contract above rather than another special-case spawn path. Please update this same contributor PR; no replacement PR or authorship rewrite is needed.
Address maintainer review (4 required changes): 1. First-class reentrant TaskExecutionHandle: created before process spawn, owns the process tree, accepts cancellation before task ID/PID exist, cleans up late-arriving task IDs, settles before retry. 2. Parent/child identity separation: nested reformat launches pass nested:true and never overwrite agent.currentTask/currentTaskId/processPid. Parent identity is immutable across the nested execution. 3. Cached parsed recovery object: evaluateStructuredSuccess stores the validated parse on state._cachedParsedResult; buildCompletionResult returns it as parsedResult so hooks never re-parse (one model call). 4. Cancellation identity end-to-end: REFORMAT_CANCELLED propagates through parseResultOutput as cancellation, never becomes a missing-JSON error. Regression tests: pre-ID cancellation, late-attach kill, parent identity retention, single recovery model call, cancellation between attempts.
…mer cleanup - Guard isolated path liveness monitoring and log output with !nested (previously unreachable but now consistent with local path guards) - Short-circuit evaluateStructuredSuccess when _cachedParsedResult is already populated, preventing a redundant second parse/model call - Store SIGKILL timer reference in TaskExecutionHandle and clear it on process settle to avoid dangling timers - Update regression test to verify cache short-circuit behavior
|
@tomdps Thanks for the thorough review and the structural guidance. All 4 required changes are implemented in
Coverage: 9 new regression tests in Additional hardening in
Ready for re-review. Let me know if you would like me to rebase onto current main first. |
tomdps
left a comment
There was a problem hiding this comment.
Approved after independent exact-head review. The OpenCode structured-output recovery, model/config provenance, cancellation and durable cleanup ownership paths are correct; the timing-only test race is now deterministic. Exact e527dca passed required, check, CodeQL, semantic, both install matrices, Release preflight, and Greptile.
|
🎉 This PR is included in version 6.12.0 🎉 The release is available on: Your semantic-release bot 📦🚀 |
Problem
When an agent runs on the opencode provider and is required to emit structured JSON (any agent with a
jsonSchema, e.g. the planner infull-workflow), the cluster frequently dies with:The cluster's backing process exits and the cluster becomes a zombie, failing the whole task.
Root cause
The opencode planner spends its turn on tool-calls (reading files to plan) and does not emit the final JSON plan block as text.
extractJsonFromOutputtherefore finds nothing to parse.parseResultOutputthen falls back toreformatOutput— the mechanism designed exactly for "the agent produced non-JSON output, reformat it into the schema". ButreformatOutputwas shipped as a stub that always rejects:So both the primary extraction and the fallback fail → the agent fails → zombie cluster.
Fix
Implement
reformatOutputusing the opencode CLI as the reformatting backend (opencode reliably emits clean JSON via--format json). Two details were essential:Disable tools in the reformat prompt. Without an explicit "do NOT use any tools / do NOT read files" instruction, opencode sees filenames mentioned in the raw output and re-enters tool-use, hanging the reformat call (>120s observed vs ~15s with the instruction).
Spawn with stdin ignored.
execFileleaves the child's stdin as an open pipe; opencode blocks waiting for stdin EOF and never returns. Usingspawnwithstdio: ['ignore', 'pipe', 'pipe']fixes this. A timeout +SIGKILLguards against any residual hang.The reformat result is still run through
extractJsonFromOutputand validated against the agent'sjsonSchema(existingvalidateAgainstSchema), with up tomaxAttemptsretries.Verification
reformatOutputreturns schema-valid JSON in ~14s.Notes / discussion
opencodebinary. If a generalprovider.callSimpleSDK lands later, this can switch to the commented design (reformat via the agent's own provider). Happy to adjust to whichever direction maintainers prefer.