Skip to content

Software factory change - #520

Draft
agent-relay-code[bot] wants to merge 2 commits into
mainfrom
relayflow/flows-software-garden-4c2e32b7
Draft

agent-relay-code[bot] wants to merge 2 commits into
mainfrom
relayflow/flows-software-garden-4c2e32b7

Conversation

@agent-relay-code

@agent-relay-code agent-relay-code Bot commented Sep 20, 2026

Copy link
Copy Markdown
Contributor

A failing named gate now says why (#507)

What the ticket reported, and what the evidence actually shows

The ticket proposed replacing stdio: 'inherit' in named-gate-lowering.ts
with spawnSync buffering, on the theory that the daemon captures the gate
command's stdio nowhere.

That is not what the code does, and the replacement would make things
worse.
kernel/relayflowd/src/exec_det.rs:78 creates Stdio::piped() for
the deterministic step's stdout and stderr and drains both on reader threads.
The lowered gate is a deterministic step, so inherit hands the author's
command those very pipes. Its bytes reach the journal as they are written.

This is not an argument from reading alone. The new
tests/named-gate-journal.test.ts starts a real relayflowd, runs a lowered
subprocess_gate, and reads step.completed.payload.output back for the
generated produce.gate step. Both tails are there, on a failing gate and on
a passing one. Those two cases passed against unmodified production code.
The ticket's stated root cause does not reproduce here, and the reported
macOS incident has not been replayed; nothing below claims it has.

Buffering would also have cost something real: the kernel SIGKILLs the whole
process group on timeout, so output held for a post-wait flush is destroyed
exactly when it is the only account of the failure. stdio: 'inherit' is kept
and now has a regression test that pins it — a gate printing a marker and then
sleeping past a 750 ms timeout still journals the marker.

The defect that is real: exits that say nothing

The gate program had five bare process.exit(1) sites, and word_count_bounds
four more. Each produced precisely the shape the ticket describes — exit_code: 1, empty stdout_tail, empty stderr_tail — but for reasons that have
nothing to do with the author's command:

  • from_output / in_output_at selected a path the producer output does not
    have (the command never ran at all);
  • the selected value could not be read as text;
  • the text contained a NUL byte;
  • spawnSync returned an error and never started the child (this is where
    an E2BIG would land);
  • the child was killed by a signal, so status is null.

A references_input gate whose binding has drifted, and a subprocess_gate
whose producer changed shape, both report as "the gate failed" with nothing
attached. That is the undiagnosable failure, and it is now fixed.

The change

packages/sdk/src/named-gate-lowering.ts only.

A fail(message) helper in the shared preamble writes one bounded line to
fd 2
and exits 1. Every non-verdict exit routes through it. Specifics:

  • writeSync(2, ...), not process.stderr.write. On a pipe the latter is
    asynchronous and the process.exit on the same line would drop the
    diagnostic — losing the message precisely when it is the only evidence.
  • stderr only, never stdout. references_input verifies via
    output_contains on stdout and word_count_bounds via an anchored decimal
    pattern, so one stray byte on fd 1 would change a verdict. Three tests assert
    stdout stays empty (or stays the bare count) when a diagnostic fires.
  • Bounded and single-line. 400 characters, with \r\n collapsed to spaces,
    so an author-controlled label cannot forge extra log lines.
  • No input, environment, or raw error object is dumped. Spawn failures
    carry the error code and the input's byte count, which is what makes an
    argument/environment-size failure diagnosable without printing the payload.

word_count_bounds additionally stops discarding its wc stderr: error,
signal, nonzero status and malformed output each get their own cause with a
bounded 200-byte suffix of what wc said. Previously a missing or broken wc
was indistinguishable from a word count out of bounds.

Deliberately unchanged, per the reviewed plan: stdio: 'inherit', the
FLOWS_INPUT transport, exit normalization, verification predicates, the
journal schema, retry policy, and the NUL guard's two-layer escaping. Ordinary
predicate mismatches in references_input / regex_match / artifact_exists
keep their bare exit 1 — a predicate that simply did not match is not a
capture failure and needs no new output.

Tests

tests/named-gate-diagnostics.test.ts (17 cases) runs the actual command
the compiler emits
— obtained through compileSpec + lowerNamedGates, not a
copied helper — under piped stdio. It covers stream capture on pass and fail,
each selection failure with a THE-COMMAND-RAN sentinel asserted absent,
NUL rejection (with a literal backslash-zero case proving legitimate input
still runs), SIGKILL, stdout non-contamination for every affected gate, and the
wc failure modes via a PATH-ordered shim.

The spawn-error case deserves a note. An oversized outer FLOWS_INPUT would
stop the test's own gate process from starting and prove nothing about the
inner spawn; a nonexistent command starts /bin/sh fine and exits 127. So the
test puts a node shim first on PATH that execs the real interpreter with
--require, and the preload hooks Module._load to return a
node:child_process whose spawnSync reports E2BIG with a null status. The
real serialized program runs against a stubbed syscall. No production injection
hook was added.

tests/named-gate-journal.test.ts (5 cases) is the acceptance evidence: a
live daemon, assertions on the persisted step.completed output of the
generated gate step. It covers a failing gate, a passing gate, a selection
diagnostic, partial tails surviving a timeout, and a gate on an agent step
— the incident's shape, where the envelope is selected whole rather than as
stdout_tail. That case also asserts the failing gate's stderr reaches the
authored failure report's message.

Acceptance

Ticket requirement Status
Failing gate journals both tails Met — named-gate-journal.test.ts, live daemon. Already true before this change; now pinned.
Passing gate journals both tails Met — same file. Already true before; now pinned.
Failure report shows the cause Met — agent-gate case asserts the marker in the authored failure message.
Test asserts journal capture of both streams Met — committed assertions on persisted output.
Other lowered gates checked Met — word_count_bounds was the other child-spawning gate and was blind to its own child's failure; fixed. artifact_exists, regex_match, references_input spawn nothing and need no change.
flows logs <run-id> shows the failure PENDING — not closed by this PR.

Why flows logs is left open

flows logs is Cloud-only. packages/sdk/src/cloud-read.ts:504 issues GET /api/v1/workflows/runs/<id>/logs; there is no local-journal fallback, and
this repository contains no producer for that log — no write side of the
route exists here. docs/CLOUD.md:449-452 states it directly: "what these
routes serve is Cloud's own record of the run, not the kernel journal."

A test could mock a log body containing these markers, but that would prove
only that the renderer prints what it is given — it could not show the gate's
stderr was ever published. The missing integration is the Cloud runner /
harness that writes runner logs
, which lives outside this repository. I have
not substituted flows replay (local-journal only) or added a new local logs
command; both would be scope the ticket did not ask for. This criterion should
be tracked against the Cloud repository.

Validation

Run from packages/sdk. The kernel target lives outside the tree
(ops/cargo.sh redirects CARGO_TARGET_DIR), and test:prep exports
RELAYFLOWD_BIN inside a subshell that does not reach vitest, so it is passed
explicitly here.

Mutation verification — production file reverted with git stash push packages/sdk/src/named-gate-lowering.ts, rebuilt, both new files run:

 ❯ tests/named-gate-diagnostics.test.ts (17 tests | 12 failed) 569ms
 ❯ tests/named-gate-journal.test.ts (5 tests | 1 failed) 1798ms
 Test Files  2 failed (2)
      Tests  13 failed | 9 passed (22)

The 9 that pass are the honest baseline: stream capture already worked.
Restored byte-for-byte with git stash pop, rebuilt, re-run:

 ✓ tests/named-gate-diagnostics.test.ts (17 tests) 545ms
 ✓ tests/named-gate-journal.test.ts (5 tests) 1629ms
 Test Files  2 passed (2)
      Tests  22 passed (22)

Required package command — RELAYFLOWD_BIN=... npm test (kernel build,
typecheck, build, test typecheck, vitest):

 ✓ tests/named-gate-diagnostics.test.ts (17 tests) 575ms
 ✓ tests/named-gate-journal.test.ts (5 tests) 1840ms
 ...
 Test Files  3 failed | 155 passed | 1 skipped (159)
      Tests  30 failed | 2393 passed | 17 skipped (2440)

The 30 failures are pre-existing and unrelated. Verified by running the
same files against the reverted production code: identical counts, identical
line numbers.

  • tests/authored-node-runtime.test.ts — suite-level failure at line 18,
    expected '1.3.6' to be '1.4.0': this sandbox's bun is older than the
    version the test pins. All 14 cases skip. This is the file that exercises
    word_count_bounds end-to-end, so it was checked first and specifically; it
    does not run here for want of a prerequisite, not because of this change.
  • tests/live-kernel.test.ts — 8 cases needing agent CLIs unavailable here.
  • tests/stuck-run-triage.test.ts — 22 cases failing in
    getFlowDefinition with "expected an @relayflows/surface flow handle", a
    surface module-resolution problem in this checkout.

No kernel code changed, so the Rust suite was not run.

tsconfig.tests.json gains the two new files so typecheck:tests covers them;
it passes clean. No file under docs/evidence and no generated file was
edited.


Note

Medium Risk
Changes generated gate runtime behavior for failure paths operators rely on for triage; scope is limited to SDK lowering and stderr-only diagnostics, with strong new tests, but any gate verification edge case could affect run outcomes.

Overview
Fixes #511 by making lowered named gates emit a single bounded stderr line when they fail before or instead of a normal command verdict—selection misses, bad input shape, NUL bytes, spawn errors, signals, and broken wc—instead of exiting 1 with empty journal tails.

named-gate-lowering.ts adds a shared fail() that uses fs.writeSync(2, …) (so the message is not dropped on immediate process.exit) and keeps diagnostics off stdout, since references_input and word_count_bounds read verdicts there. stdio: 'inherit' for subprocess_gate is unchanged; new tests pin that stream capture and journal persistence via named-gate-diagnostics.test.ts and live-daemon named-gate-journal.test.ts.

The PR also adds evidence/511-named-gate-diagnostics/ and summary.md with mutation checks and test transcripts. flows logs showing gate stderr in Cloud is explicitly not closed here (no log producer in-repo).

Reviewed by Cursor Bugbot for commit 200e7c2. Bugbot is set up for automated code reviews on this repo. Configure here.


Summary by cubic

Fixes #511: named gates that failed before running their command used to exit 1 with empty stdout and stderr tails, so a drifted binding and a dead child were indistinguishable from a real gate failure. Every non-verdict exit now writes one bounded line to stderr with the cause, and stdio: 'inherit' is kept because the kernel already journals the command's output as written — buffering would lose it on timeout.

Bug Fixes

  • word_count_bounds reports why wc failed (missing or broken wc, signal, malformed output) instead of looking like an out-of-bounds count.
  • Diagnostics are stderr-only and capped at 400 characters, so the stdout that references_input and word_count_bounds verify against stays clean.
  • Adds tests that run the emitted gate command under piped stdio and against a live daemon, covering pass, fail, timeout, and gates on agent steps.
  • Adds captured transcripts under evidence/511-named-gate-diagnostics/ so the mutation and baseline runs are checkable.
  • flows logs <run-id> is intentionally not closed; it reads Cloud's runner log, and no producer for that route exists in this repository.

Written for commit 200e7c2. Summary will update on new commits.

Review in cubic

A lowered gate had five bare `process.exit(1)` sites, and word_count_bounds
four more, each producing exit 1 with empty stdout_tail and stderr_tail for
reasons unrelated to the author's command: a from_output/in_output_at path
the producer output does not have, a non-text selection, a NUL byte, a child
that never started, a child killed by a signal. A binding that drifted was
indistinguishable from a gate that genuinely failed.

Route every non-verdict exit through a bounded `fail()` that writes one
400-character line to fd 2 via writeSync — process.stderr.write is async on a
pipe and the adjacent process.exit would drop the diagnostic. Nothing goes to
fd 1, because references_input and word_count_bounds read their verdict from
stdout. Spawn failures carry the error code and the input's byte count, so an
argument/environment-size failure is diagnosable without printing the payload.
word_count_bounds stops discarding its wc stderr.

Keep `stdio: 'inherit'` rather than the buffering the ticket proposed. The
kernel pipes and drains a deterministic step's stdout/stderr (exec_det.rs:78),
so the command's bytes already reach the journal as written; buffering them
for a post-wait flush would lose exactly what a process-group SIGKILL on
timeout makes most valuable. Live-daemon tests confirm both tails were already
journaled before this change, and now pin it — including partial output
surviving a timeout.

`flows logs <run-id>` is left explicitly unmet: it reads Cloud's runner log
and no producer for that route exists in this repository. See summary.md.

Co-Authored-By: Claude <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Sep 20, 2026

Copy link
Copy Markdown

Important

Review skipped

Bot user detected.

To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Advanced

Run ID: b5455e7f-12c9-4453-9e7b-597280c09955

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

The review rejected the report, not the code: it labeled the change
mutation-verified while supplying only aggregate counts, elided the daemon
path to `...`, and dismissed a red package run as pre-existing without the
baseline invocation or its output.

Re-execute everything and commit the transcripts under
evidence/511-named-gate-diagnostics/, each one the captured output of the
command that heads it, ending in its exit status. The mutation halves now
record blob hashes against `git rev-parse` at both revisions and an empty
`git status`, so "byte-for-byte" is checkable. The pre-existing claim now
rests on a baseline run of the same three files with packages/sdk reverted to
e21caad in the same working directory: 30 failed, 23 passed, 14 skipped at
both revisions, same cases and line numbers. 22 of those failures are two
reachable installs of @relayflows/surface, one in an ancestor of the checkout,
missing the WeakMap brand check — literal resolver output included.

State the `flows logs <run-id>` gap as a named work item: the Cloud runner
must publish a deterministic step's tails into the logs route, no producer for
it exists here, and #511 is not fully closed until that end-to-end evidence
exists.

Correct the ticket number. The work implements #511; the first commit, the PR
title and the PR body cite #507, which is the f.gitlab writeback issue. The
pushed commit is not amended, and the PR body still needs the same fix.

Co-Authored-By: Claude <noreply@anthropic.com>
@agent-relay-code

Copy link
Copy Markdown
Contributor Author

Relayflow: the adversarial review did not pass. This branch is not approved: the flow stopped here and did not mark it ready to merge.

Review of PR #520, head 200e7c2fb6d556e79501182771ab9845677e9e6f, base e21caad1e4020d9e2675d2840301cb7eeb6f1aac.

Disposition: no production-code defect identified in this diff; ticket acceptance remains incomplete. review.clean is not created.

  1. Open acceptance gap — demonstrate gate evidence in flows logs <run-id>. summary.md correctly leaves this pending. The new tests read the local journal and the authored exception, while getCloudRunLog in packages/sdk/src/cloud-read.ts:504 reads a separate Cloud log response. There is no end-to-end publication evidence in this PR. Before closing A failing subprocess_gate journals empty stdout/stderr: the lowering uses stdio:'inherit' and the daemon's stdio is captured nowhere #511, link a Cloud runner/harness follow-up and capture an actual failing gate whose stdout/stderr appear in flows logs. This is an unmet ticket criterion, not a newly introduced regression; the reviewed plan explicitly permits this external dependency to remain pending for the focused SDK patch.

  2. PR description needs synchronization (nonblocking). The fetched body still identifies the issue as f.gitlab is comment-only while f.github has full writeback, and the provider catalog marks both supported: true #507 and uses the earlier abbreviated validation narrative, whereas the committed report corrects the issue to A failing subprocess_gate journals empty stdout/stderr: the lowering uses stdio:'inherit' and the daemon's stdio is captured nowhere #511 and includes full transcripts. Update the body to link the revised evidence and preserve the pending acceptance item. The added bot summary says “Fixes A failing subprocess_gate journals empty stdout/stderr: the lowering uses stdio:'inherit' and the daemon's stdio is captured nowhere #511,” which overstates completion given that pending item. No remote description or comment was edited during this review.

The previous evidence finding is resolved in the committed report: evidence/511-named-gate-diagnostics/ now contains commands, failure output, restoration hashes, and the same-directory baseline. These are author-supplied experiment records, not experiments rerun by this reviewer. I do not claim independent mutation verification or reproduction of the macOS incident.

The implementation follows the reviewed plan. Inherited child descriptors are the deterministic executor's capture pipes (kernel/relayflowd/src/exec_det.rs:78), and retaining them avoids losing buffered output on timeout. The new synchronous stderr helper diagnoses selection failures, NUL input, spawn errors, signals, and broken wc execution without contaminating stdout predicates. Tests exercise the actual generated program and real journal completions, including an agent producer. No kernel code changed; the Rust test suite was not run.

All available PR comments and reviews were fetched, including paginated issue comments, inline comments, and submitted reviews. The only discussion comment is CodeRabbit's explicit skipped-review notice; inline comments and submitted reviews are empty. The body also contains automated summaries. Exact commands and full responses are captured in pr.txt.

Supplementary edge checks used edges.mjs. They exercise generated-command capture, not journal persistence. Literal command and captured output (transcript):

$ node review-evidence/pr520/edges.mjs
large inherited streams: exit=1 stdout=200000 bytes stderr=200000 bytes
long selection diagnostic: exit=1 stdout=empty stderr=418 bytes, one line
shell metacharacters in selection: exit=1 stdout=empty, diagnostic remains one line
EXIT=0

The affected package was run at this head. Full command and all captured output, including the failing assertions and stacks: npm-test.txt.

cd packages/sdk
RELAYFLOWD_BIN=/home/daytona/.relayflows-toolchain/target/2962130851/debug/relayflowd npm test

Literal result excerpts:

 ✓ tests/named-gate-diagnostics.test.ts (17 tests) 586ms
 ✓ tests/named-gate-journal.test.ts (5 tests) 2300ms
 Test Files  3 failed | 155 passed | 1 skipped (159)
      Tests  30 failed | 2393 passed | 17 skipped (2440)
EXIT=1

Kernel build, SDK typecheck/build, and test typecheck completed before Vitest; their output is in the same transcript. The package is not green. Failures are in live-kernel.test.ts (8 cases), stuck-run-triage.test.ts (22 cases), and the suite-level Bun version check in authored-node-runtime.test.ts (1.3.6 versus 1.4.0, with 14 cases skipped). The author supplies a same-directory baseline in baseline-in-place-e21caad.txt; this reviewer did not rerun that baseline. The red package run is not presented as successful verification.

git diff --check e21caad HEAD exited 2 for trailing whitespace in captured test transcripts; the command and complete output are in diff-check.txt. These are literal test-output lines, not production source defects; the transcripts were left intact.

Only review.md and review evidence were written. Existing plan files and prior review evidence were retained. No implementation, tests, judging gates, docs/evidence, or tracked generated files were edited. No commit, merge, or remote write was made.

@agent-relay-code
agent-relay-code Bot marked this pull request as draft September 20, 2026 18:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A failing subprocess_gate journals empty stdout/stderr: the lowering uses stdio:'inherit' and the daemon's stdio is captured nowhere

0 participants