Summary
A failing subprocess_gate is undiagnosable. The lowering runs the author's command with stdio: 'inherit', so its stdout and stderr go to the daemon's stdio — which is captured nowhere. The journal records exit_code: 1 with empty stdout_tail and empty stderr_tail, relayflowd.log stays 0 bytes, and the CLI's own output never sees it. The operator is told only that a gate failed.
Evidence
Hit while running a 58-step authored campaign (relay.migrate.native-delivery.phase-0, run 01M2Z2Y20NJAA25RDXJKNRC054, CLI 2.0.22, macOS arm64). The agent step succeeded; its subprocess_gate failed three times and exhausted retries.
The lowering, packages/sdk/src/named-gate-lowering.ts:95-99:
case 'subprocess_gate':
body = `if(text.includes('\\0'))process.exit(1);
const result=cp.spawnSync('/bin/sh',['-c',${JSON.stringify(gate.command)}],{
env:{...process.env,INPUT:text},stdio:'inherit'});
process.exit(result.status===0?0:1);`;
What the journal recorded for all three attempts:
{"completionReason":"retries_exhausted","disposition":"step_done",
"output":{"exit_code":1,"stderr_tail":"","stdout_tail":""},
"trajectory_tail":{"exit_code":1,"stderr_tail":""},
"verification":{"detail":"exit code was 1","gate":"exit_code","verdict":"fail"}}
The gate command was a script that prints GATE_PASSED <detail> to stdout or GATE_FAILED <problems> to stderr on every path. Neither string appears in:
- the journal (
stdout_tail and stderr_tail both ""),
.relayflowd/relayflowd.log (0 bytes),
- the
flows run CLI output.
Compare a plain deterministic step running the same script, which journals its output correctly:
{"output":{"exit_code":0,"stdout_tail":"GATE_PASSED preflight phase=0 slug=seam branch=... base=...\n"}}
So the capture works everywhere except inside a lowered named gate.
Why it matters
subprocess_gate exists so a step's completion can be decided by a real check. In practice that check is a script with several failure modes, and the one thing the author needs on a red verdict is which one fired. Today the answer is "re-run it by hand and hope the tree still looks the same" — and for a gate that follows an agent step, the tree does not still look the same.
It also interacts badly with retries_exhausted (#506): three silent attempts collapse into one bare exit=1.
What to change
Capture the subprocess's output and journal it, the way a deterministic step's output is captured. Replacing stdio: 'inherit' with a pipe and forwarding the tails into the gate step's output would be enough:
const result=cp.spawnSync('/bin/sh',['-c',CMD],{env:{...process.env,INPUT:text},encoding:'utf8'});
process.stdout.write(result.stdout ?? '');
process.stderr.write(result.stderr ?? '');
process.exit(result.status===0?0:1);
The lowered gate is already a deterministic step, so its stdout_tail / stderr_tail are journaled — the output just has to reach them.
Worth checking the other lowered gates for the same pattern; artifact_exists and regex_match decide in-process and are unaffected, but anything spawning a child has the same exposure.
Acceptance
- A failing
subprocess_gate journals the command's stdout and stderr tails, visible in flows logs <run-id> and in the failure report.
- A passing one does too, so a gate that passes for the wrong reason is still inspectable.
- A test asserts a gate command writing to stdout and stderr has both captured in the journal.
Out of scope
- Whether
subprocess_gate should receive the output envelope through INPUT at all.
- Retry policy.
Summary
A failing
subprocess_gateis undiagnosable. The lowering runs the author's command withstdio: 'inherit', so its stdout and stderr go to the daemon's stdio — which is captured nowhere. The journal recordsexit_code: 1with emptystdout_tailand emptystderr_tail,relayflowd.logstays 0 bytes, and the CLI's own output never sees it. The operator is told only that a gate failed.Evidence
Hit while running a 58-step authored campaign (
relay.migrate.native-delivery.phase-0, run01M2Z2Y20NJAA25RDXJKNRC054, CLI 2.0.22, macOS arm64). The agent step succeeded; itssubprocess_gatefailed three times and exhausted retries.The lowering,
packages/sdk/src/named-gate-lowering.ts:95-99:What the journal recorded for all three attempts:
{"completionReason":"retries_exhausted","disposition":"step_done", "output":{"exit_code":1,"stderr_tail":"","stdout_tail":""}, "trajectory_tail":{"exit_code":1,"stderr_tail":""}, "verification":{"detail":"exit code was 1","gate":"exit_code","verdict":"fail"}}The gate command was a script that prints
GATE_PASSED <detail>to stdout orGATE_FAILED <problems>to stderr on every path. Neither string appears in:stdout_tailandstderr_tailboth""),.relayflowd/relayflowd.log(0 bytes),flows runCLI output.Compare a plain deterministic step running the same script, which journals its output correctly:
{"output":{"exit_code":0,"stdout_tail":"GATE_PASSED preflight phase=0 slug=seam branch=... base=...\n"}}So the capture works everywhere except inside a lowered named gate.
Why it matters
subprocess_gateexists so a step's completion can be decided by a real check. In practice that check is a script with several failure modes, and the one thing the author needs on a red verdict is which one fired. Today the answer is "re-run it by hand and hope the tree still looks the same" — and for a gate that follows an agent step, the tree does not still look the same.It also interacts badly with
retries_exhausted(#506): three silent attempts collapse into one bareexit=1.What to change
Capture the subprocess's output and journal it, the way a deterministic step's output is captured. Replacing
stdio: 'inherit'with a pipe and forwarding the tails into the gate step'soutputwould be enough:The lowered gate is already a deterministic step, so its
stdout_tail/stderr_tailare journaled — the output just has to reach them.Worth checking the other lowered gates for the same pattern;
artifact_existsandregex_matchdecide in-process and are unaffected, but anything spawning a child has the same exposure.Acceptance
subprocess_gatejournals the command's stdout and stderr tails, visible inflows logs <run-id>and in the failure report.Out of scope
subprocess_gateshould receive the output envelope throughINPUTat all.