Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 147 additions & 0 deletions bench/axis_sdd_task_result.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
package main

import (
"bytes"
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
)

const sddTaskResultAxis = "sdd-task-result"

func init() {
RegisterAxis(Axis{
Name: sddTaskResultAxis,
Title: "OpenCode SDD task-result transport failures",
BlackBox: false,
Properties: []string{
"Runs the installed OpenCode plugin through Node with a provider-shaped empty task_result; it does not drive the gentle-ai CLI alone.",
"Requires GENTLE_AI_BENCH_SDD_PLUGIN and a Node runtime that executes .mts files with built-in TypeScript type stripping; skips honestly when the plugin or runtime capability is unavailable.",
},
Journeys: sddTaskResultJourneys,
})
}

func sddTaskResultJourneys() []Journey {
return []Journey{{
ID: "tr01-sdd-empty-task-result",
Title: "Empty SDD task result: typed terminal failure without artifact mutation or downstream launch",
Source: "issue #2117 provider transport report",
Steps: []Step{{
Name: "provider-shaped empty task result reaches the installed OpenCode plugin",
Skip: sddTaskResultUnavailable,
Composite: sddEmptyTaskResult,
}},
}}
}

func sddTaskResultUnavailable(*Sandbox) string {
path := os.Getenv("GENTLE_AI_BENCH_SDD_PLUGIN")
if path == "" {
return "GENTLE_AI_BENCH_SDD_PLUGIN is not set"
}
if info, err := os.Stat(path); err != nil || !info.Mode().IsRegular() {
return "GENTLE_AI_BENCH_SDD_PLUGIN does not name an installed plugin"
}
node, err := exec.LookPath("node")
if err != nil {
return "node is unavailable"
}
return sddTaskResultNodeUnavailable(node)
}

func sddTaskResultNodeUnavailable(node string) string {
dir, err := os.MkdirTemp("", "gentle-ai-sdd-task-result-node-*")
if err != nil {
return "node TypeScript capability check could not create a temporary directory"
}
defer os.RemoveAll(dir)
capability := filepath.Join(dir, "capability.mts")
if err := os.WriteFile(capability, []byte(`const marker: string = "gentle-ai-sdd-task-result"`), 0o600); err != nil {
return "node TypeScript capability check could not write a temporary .mts file"
}
ctx, cancel := context.WithTimeout(context.Background(), sddTaskResultHarnessTimeout)
defer cancel()
output, err := exec.CommandContext(ctx, node, capability).CombinedOutput()
if err == nil {
return ""
}
detail := strings.TrimSpace(string(output))
if detail == "" && ctx.Err() != nil {
detail = ctx.Err().Error()
}
if detail == "" {
detail = err.Error()
}
return fmt.Sprintf("node cannot execute .mts TypeScript with type stripping required by the installed plugin/harness: %s", detail)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const sddTaskResultHarness = `import { readFile, writeFile } from "node:fs/promises"
const source = process.argv[2]
const cwd = process.argv[3]
await writeFile("./plugin.mts", await readFile(source))
const { default: plugin } = await import("./plugin.mts")
const hooks = await plugin({ directory: cwd, worktree: cwd })
const artifact = cwd + "/proposal.md"
await writeFile(artifact, "existing artifact")
const input = { tool: "task", sessionID: "bench-sdd", callID: "empty", args: { subagent_type: "sdd-propose", prompt: "create a proposal" } }
let failure = "NO_ERROR"
try {
await hooks["tool.execute.after"](input, { title: "", output: "<task id=\"phase\" state=\"completed\">\n<task_result>\n\n</task_result>\n</task>", metadata: {} })
} catch (cause) {
failure = cause instanceof Error ? cause.message : String(cause)
}
let downstream = "NO_ERROR"
try {
await hooks["tool.execute.before"]({ ...input, callID: "downstream", args: { subagent_type: "sdd-apply", prompt: "continue" } }, { args: { subagent_type: "sdd-apply", prompt: "continue" } })
} catch (cause) {
downstream = cause instanceof Error ? cause.message : String(cause)
}
console.log([failure, downstream, await readFile(artifact, "utf8")].join("\n---\n"))
`

const sddTaskResultHarnessTimeout = 30 * time.Second

func sddEmptyTaskResult(r *journeyRun) error {
root := filepath.Join(r.sandbox.Root, "sdd-task-result")
work := filepath.Join(root, "work")
if err := os.MkdirAll(work, 0o755); err != nil {
return err
}
if err := os.WriteFile(filepath.Join(root, "harness.mts"), []byte(sddTaskResultHarness), 0o600); err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), sddTaskResultHarnessTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, "node", "harness.mts", os.Getenv("GENTLE_AI_BENCH_SDD_PLUGIN"), work)
cmd.Dir = root
cmd.Env = r.sandbox.env()
var output bytes.Buffer
cmd.Stdout, cmd.Stderr = &output, &output
err := cmd.Run()
observation := Observation{Args: []string{"opencode", "task", "sdd-propose"}, ExitCode: 0, Stdout: output.String(), StdoutCaptured: true, StderrCaptured: true}
if err != nil {
observation.ExitCode = 1
observation.Stderr = output.String()
}
record := r.accumulator.observe(r.step, observation, nil, true)
r.accumulator.records = append(r.accumulator.records, record)
if err != nil {
return fmt.Errorf("run OpenCode task-result harness: %w: %s", err, output.String())
}
parts := strings.Split(strings.TrimSpace(output.String()), "\n---\n")
if len(parts) != 3 {
return fmt.Errorf("task-result harness output = %q", output.String())
}
if !strings.Contains(parts[0], "sdd_task_result_empty") || !strings.Contains(parts[1], "sdd_task_result_empty") {
return fmt.Errorf("empty result was not routed as one typed terminal failure: %q", parts[:2])
}
if parts[2] != "existing artifact" {
return fmt.Errorf("task-result failure mutated artifact state: %q", parts[2])
}
return nil
}
14 changes: 12 additions & 2 deletions bench/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -364,8 +364,11 @@ func (p *capabilityProbe) probed(argv []string) (bool, string) {
// Step is one unit of a journey. Journeys are data: adding one is adding a
// Step to a slice.
type Step struct {
Name string
Fixture func(*Sandbox) error
Name string
Fixture func(*Sandbox) error
// Skip reports why an externally-backed step cannot run in this environment.
// The runner records the journey as unsupported rather than a false pass.
Skip func(*Sandbox) string
Requires *Capability
Args func(*Sandbox) ([]string, error)
// Composite drives a multi-command sub-flow (a lens loop, a rejected
Expand Down Expand Up @@ -506,6 +509,13 @@ func runJourney(binary string, journey Journey) JourneyResult {

for _, step := range journey.Steps {
run.step = step.Name
if step.Skip != nil {
if reason := step.Skip(run.sandbox); reason != "" {
result.Status = StatusUnsupported
result.UnsupportedSteps = append(result.UnsupportedSteps, step.Name+" ("+reason+")")
break
}
}

if step.Fixture != nil {
if err := step.Fixture(sandbox); err != nil {
Expand Down
8 changes: 7 additions & 1 deletion internal/assets/assets_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -530,7 +530,7 @@ func TestReviewResultArtifactsPluginContract(t *testing.T) {
`return JSON.stringify([binding.lineage, binding.target, binding.revision, binding.repository_context, binding.lens, binding.order, binding.subject_hash])`,
`const recovery = { sessionID: input.sessionID, store: admissionRecoveries }`,
`event.type === "session.deleted"`,
`dispose: async () => { admissionRecoveries.clear() }`,
"dispose: async () => {\n admissionRecoveries.clear()\n failedSDDSessions.clear()\n }",
`MAX_ADMISSION_RECOVERY_SESSIONS`,
`MAX_ADMISSION_RECOVERIES_PER_SESSION`,
`sessionErrorMessage(binding, cause, "repository_context_lens_context_failed")`,
Expand All @@ -542,6 +542,12 @@ func TestReviewResultArtifactsPluginContract(t *testing.T) {
// message, and the plugin must thread that class into --class.
`"reviewer task result is empty"`,
`"reviewer task result contains a nested task envelope"`,
`const SDD_PHASES`,
`const SDD_TASK_FAILURE_PREFIX`,
`"gentle-ai.sdd-task-result-failure/v1"`,
`"sdd_task_result_empty"`,
`"sdd_task_result_malformed"`,
`failedSDDSessions`,
`reviewClass`,
`extractionClass(cause)`,
`"--class"`,
Expand Down
106 changes: 94 additions & 12 deletions internal/assets/opencode/plugins/review-result-artifacts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ const REVIEW_AGENTS = new Set(["review-risk", "review-resilience", "review-reada
const BINDING = /^GENTLE_AI_REVIEW_BINDING (\{[^\n]+\})(?:\n|$)/
const TASK_RESULT = /^<task id="[^"\r\n]+" state="completed">\n<task_result>\n([\s\S]*?)\n<\/task_result>\n<\/task>$/
const TASK_TAG = /<\/?task(?:\s|>)|<\/?task_result>/
const SDD_PHASES = ["sdd-init", "sdd-explore", "sdd-propose", "sdd-spec", "sdd-design", "sdd-tasks", "sdd-apply", "sdd-verify", "sdd-archive", "sdd-onboard"]

// LENS_CONTEXT_DELIVERY declares this plugin's mechanism to the provider, and
// it is recorded on the receipt beside the captured results. It is the
Expand Down Expand Up @@ -215,28 +216,82 @@ function parseBinding(prompt: unknown, lens: string): ReviewBinding {
return value as ReviewBinding
}

function reviewerResult(output: unknown): string {
if (typeof output !== "string" || output.trim() === "") throw new Error("reviewer output must not be empty")
function taskResult(output: unknown, subject: string, classification: string, classifyEmptyOutput = true): string {
const fail = (message: string, taskResultClass: string): never => {
throw Object.assign(new Error(message), { [classification]: taskResultClass })
}
if (typeof output !== "string" || output.trim() === "") {
if (classifyEmptyOutput) fail(`${subject} output must not be empty`, "empty_result")
throw new Error(`${subject} output must not be empty`)
}
const trimmed = output.trim()
const envelope = TASK_RESULT.exec(trimmed)
if (!envelope) {
if (TASK_TAG.test(trimmed)) throw new Error("reviewer output contains a malformed task result envelope")
if (TASK_TAG.test(trimmed)) fail(`${subject} output contains a malformed task result envelope`, "malformed_result")
return trimmed
}
if (envelope[1].trim() === "") {
throw Object.assign(new Error("reviewer task result is empty"), { reviewClass: "empty_result" })
fail(`${subject} task result is empty`, "empty_result")
}
if (TASK_TAG.test(envelope[1])) {
throw Object.assign(new Error("reviewer task result contains a nested task envelope"), { reviewClass: "nested_envelope" })
fail(`${subject} task result contains a nested task envelope`, "nested_envelope")
}
return envelope[1]
}

function extractionClass(cause: unknown): string | undefined {
const value = (cause as { reviewClass?: unknown } | null)?.reviewClass
function reviewerResult(output: unknown): string {
if (typeof output !== "string" || output.trim() === "") throw new Error("reviewer output must not be empty")
try {
return taskResult(output, "reviewer", "reviewClass", false)
} catch (cause) {
switch (extractionClass(cause)) {
case "empty_result":
throw Object.assign(new Error("reviewer task result is empty"), { reviewClass: "empty_result" })
case "nested_envelope":
throw Object.assign(new Error("reviewer task result contains a nested task envelope"), { reviewClass: "nested_envelope" })
case "malformed_result":
throw new Error("reviewer output contains a malformed task result envelope")
default:
throw cause
}
}
}

function extractionClass(cause: unknown, property = "reviewClass"): string | undefined {
const value = (cause as Record<string, unknown> | null)?.[property]
return typeof value === "string" ? value : undefined
}

function isSDDPhase(agent: string): boolean {
return SDD_PHASES.some((phase) => agent === phase || agent.startsWith(phase + "-"))
}
const SDD_TASK_FAILURE_PREFIX = "GENTLE_AI_SDD_FAILURE "
type SDDTaskFailure = { phase: string, code: string, handoff: string }
type SDDTaskFailureError = Error & { sddFailure: SDDTaskFailure }
function shellQuote(value: string): string {
return `'${value.replace(/'/g, "'\\''")}'`
}
function sddTaskFailure(phase: string, cwd: string, cause: unknown): SDDTaskFailureError {
const classification = extractionClass(cause, "sddClass")
const code = classification === "empty_result" ? "sdd_task_result_empty" : "sdd_task_result_malformed"
const failure: SDDTaskFailure = {
phase,
code,
handoff: SDD_TASK_FAILURE_PREFIX + JSON.stringify({
schemaName: "gentle-ai.sdd-task-result-failure/v1",
status: "blocked",
code,
phase,
summary: `${phase} returned no valid task result. Do not retry or advance SDD; inspect the existing artifact state and surface the terminal failure to the user.`,
continuation: `gentle-ai sdd-status --cwd ${shellQuote(cwd)} --json`,
}),
}
return Object.assign(
new Error(failure.handoff),
{ sddFailure: failure },
) as SDDTaskFailureError
}

function captureCwd(worktree: string | undefined, directory: string): string {
return worktree || directory
}
Expand Down Expand Up @@ -613,14 +668,29 @@ async function preservedCaptureFailure(

const ReviewResultArtifactsPlugin: Plugin = async ({ client, directory, worktree }) => {
const admissionRecoveries: AdmissionRecoveryStore = new Map()
const failedSDDSessions = new Map<string, SDDTaskFailure>()
return {
dispose: async () => { admissionRecoveries.clear() },
dispose: async () => {
admissionRecoveries.clear()
failedSDDSessions.clear()
},
event: async ({ event }) => {
if (event.type === "session.deleted") admissionRecoveries.delete(event.properties.info.id)
if (event.type === "session.deleted") {
admissionRecoveries.delete(event.properties.info.id)
failedSDDSessions.delete(event.properties.info.id)
}
},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
"tool.execute.before": async (input, output) => {
if (input.tool !== "task" || typeof output.args?.subagent_type !== "string" ||
!REVIEW_AGENTS.has(output.args.subagent_type)) return
if (input.tool !== "task" || typeof output.args?.subagent_type !== "string") return
const subagent = output.args.subagent_type
if (isSDDPhase(subagent)) {
const failure = failedSDDSessions.get(input.sessionID)
if (failure) {
throw new Error(failure.handoff)
}
return
}
if (!REVIEW_AGENTS.has(subagent)) return
if (typeof output.args.prompt !== "string") {
throw new Error("review task is missing GENTLE_AI_REVIEW_BINDING")
}
Expand Down Expand Up @@ -669,7 +739,19 @@ const ReviewResultArtifactsPlugin: Plugin = async ({ client, directory, worktree
)
},
"tool.execute.after": async (input, output) => {
if (input.tool !== "task" || typeof input.args?.subagent_type !== "string" || !REVIEW_AGENTS.has(input.args.subagent_type)) return
if (input.tool !== "task" || typeof input.args?.subagent_type !== "string") return
const subagent = input.args.subagent_type
if (isSDDPhase(subagent)) {
try {
taskResult(output.output, "SDD phase", "sddClass")
} catch (cause) {
const failure = sddTaskFailure(subagent, captureCwd(worktree, directory), cause)
failedSDDSessions.set(input.sessionID, failure.sddFailure)
throw failure
}
return
}
if (!REVIEW_AGENTS.has(subagent)) return
if (typeof input.args.prompt !== "string" || !BINDING.test(input.args.prompt)) return
const lens = input.args.subagent_type
const binding = parseBinding(input.args.prompt, lens)
Expand Down
2 changes: 2 additions & 0 deletions internal/assets/opencode/sdd-orchestrator.md
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,8 @@ In **Automatic** mode the orchestrator is the gatekeeper between phases. The gat

**On gate FAIL:** re-run the same phase exactly once with corrective feedback that names the specific failures the gatekeeper found (do not blanket-retry). Re-run the gate on the new result. If it passes, continue the chain. If it fails again, STOP the automatic chain and surface a report to the user naming the phase, what the gatekeeper caught, both attempts, and the recommended fix. Do not advance to dependent phases on a failed gate β€” a bad artifact compounds downstream.

An `sdd_task_result_empty` or `sdd_task_result_malformed` failure is a transport failure, not a gate failure: do NOT retry it automatically, create or promote artifacts, or launch another SDD phase. The failure begins with `GENTLE_AI_SDD_FAILURE ` followed by a `gentle-ai.sdd-task-result-failure/v1` JSON handoff. Preserve that JSON unchanged, run its `continuation` exactly once to read the current state, then surface the typed terminal failure and wait for an explicit user decision.

The gatekeeper runs in addition to the Review Workload Guard and the Mandatory Delegation Triggers; it never relaxes them and never auto-marks anything reviewed in engram.

### Native Runtime Attempt Authority (MANDATORY)
Expand Down
Loading
Loading