Skip to content

Fix piped workflow JSON output truncation - #2389

Merged
Wirasm merged 3 commits into
devfrom
archon/task-archon-fix-github-issue-experimental-1785738722065
Aug 3, 2026
Merged

Fix piped workflow JSON output truncation#2389
Wirasm merged 3 commits into
devfrom
archon/task-archon-fix-github-issue-experimental-1785738722065

Conversation

@Wirasm

@Wirasm Wirasm commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Problem: large workflow status --json --verbose and workflow get --json --verbose payloads could be silently truncated when stdout was piped, while the CLI exited successfully.
  • Why it matters: pipe consumers received invalid machine-readable JSON without an error signal.
  • What changed: preserve normal runtime shutdown and await the stdout callback for the large affected workflow JSON payloads; add lifecycle and backpressure regression coverage.
  • What did not change (scope boundary): JSON schemas, command options, database behavior, and unrelated CLI output paths are unchanged.

UX Journey

Before

User / script              Archon CLI                 Pipe consumer
─────────────              ──────────                 ─────────────
workflow status --json ─▶ serializes payload ──────▶ receives partial JSON
                           force-exits
exit 0 ◀──────────────────────────────────────────── parse fails

After

User / script              Archon CLI                 Pipe consumer
─────────────              ──────────                 ─────────────
workflow status --json ─▶ serializes payload ──────▶ receives complete JSON
                           [awaits stdout write]
                           [allows normal shutdown]
exit 0 ◀──────────────────────────────────────────── parses successfully

Architecture Diagram

Before

workflow command ──▶ console/stdout write ──▶ CLI entry-point force exit ──▶ pipe

After

workflow command [~] ──▶ awaited stdout callback [~] ──▶ CLI entry point [~] ──▶ pipe
                                                          normal shutdown

Connection inventory (list every module-to-module edge, mark changes):

From To Status Notes
commands/workflow.ts process.stdout modified Awaits write completion for large status and verbose-get JSON output.
cli.ts Node/Bun process lifecycle modified Sets exit code without forcing termination.
CLI tests CLI/workflow sources modified Guards lifecycle and stdout backpressure behavior.

Label Snapshot

  • Risk: risk: low
  • Size: size: S
  • Scope: cli, tests
  • Module: cli:workflow-output

Change Metadata

  • Change type: bug
  • Primary scope: cli

Linked Issue

Validation Evidence (required)

Commands and result summary:

bun run type-check
bun run lint
bun run format:check
bun run test
bun run build
  • All commands passed: 4,907 tests passed, with zero lint errors or warnings.
  • Evidence provided: Swift Process + Pipe() regression captured parseable output in 16/16 runs for workflow status --json --verbose (363,722 bytes each) and 16/16 runs for workflow get --json --verbose (84,459–87,307 bytes); the not-found JSON envelope also parsed fully with exit status 1.
  • If any command is intentionally skipped, explain why: None.

Security Impact (required)

  • New permissions/capabilities? No
  • New external network calls? No
  • Secrets/tokens handling changed? No
  • File system access scope changed? No
  • If any Yes, describe risk and mitigation: Not applicable.

Compatibility / Migration

  • Backward compatible? Yes
  • Config/env changes? No
  • Database migration needed? No
  • If yes, exact upgrade steps: Not applicable.

Human Verification (required)

What was personally validated beyond CI:

  • Verified scenarios: real non-blocking pipe captures for large verbose status and get JSON output; regular-file diagnostic capture.
  • Edge cases checked: not-found JSON error envelope flushes completely and retains exit code 1.
  • What was not verified: every other JSON command under an equivalent large-payload pipe, though the CLI lifecycle correction applies globally.

Side Effects / Blast Radius (required)

  • Affected subsystems/workflows: CLI entry-point shutdown and workflow status/get JSON output.
  • Potential unintended effects: process shutdown now waits for pending stdout completion; commands may remain alive briefly while a slow downstream pipe consumes output.
  • Guardrails/monitoring for early detection: source-level lifecycle regression guard and stdout-backpressure command tests.

Rollback Plan (required)

  • Fast rollback command/path: revert commit 634f1ba7.
  • Feature flags or config toggles (if any): None.
  • Observable failure symptoms: large workflow JSON piped to another process ends mid-document while the command exits 0.

Risks and Mitigations

  • Risk: a stalled pipe can delay completion while the stdout callback is pending.
    • Mitigation: awaiting is limited to the two documented large-payload paths implicated by the issue; regression tests cover callback backpressure.

Summary by CodeRabbit

  • Bug Fixes

    • Improved reliability of machine-readable CLI output, including JSON sent through piped commands.
    • Prevented truncated or incomplete JSON responses during CLI execution.
    • Ensured command failures correctly propagate through pipelines.
    • Preserved existing human-readable output behavior.
  • Tests

    • Added coverage for JSON delivery, formatting, errors, and non-blocking shell pipes.
    • Expanded CLI test coverage across workflow, validation, and listing commands.
    • Improved filesystem-related test compatibility across platforms.

Large workflow JSON payloads could be truncated when Bun hit pipe backpressure while the CLI still exited successfully.

Changes:
- Preserve CLI exit codes without forcing process termination
- Await stdout completion for large status and verbose get payloads
- Add lifecycle and stdout backpressure regression coverage

Fixes #2384
@coderabbitai

coderabbitai Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 2b09549f-49ab-4ea0-8a3e-ca3610843210

📥 Commits

Reviewing files that changed from the base of the PR and between 7a2f24f and a4ed323.

📒 Files selected for processing (1)
  • packages/cli/src/cli.test.ts

📝 Walkthrough

Walkthrough

The CLI adds callback-aware stdout utilities for machine-readable output. Workflow, AI, and validation commands use asynchronous JSON-line writes. Tests capture stdout writes and verify complete piped output, JSON payloads, and exit statuses.

Changes

CLI JSON stdout delivery

Layer / File(s) Summary
Stdout delivery contract
packages/cli/src/utils/stdout.ts, packages/cli/src/utils/stdout.test.ts
Added writeStdout and writeJsonLine. Tests compare piped and file output and verify failure status propagation.
Command JSON output migration
packages/cli/src/commands/workflow.ts, packages/cli/src/commands/ai.ts, packages/cli/src/commands/validate.ts
Workflow, AI, and validation JSON paths now use awaited stdout utilities for results, errors, and acknowledgements.
Output validation and CLI lifecycle
packages/cli/src/commands/*.test.ts, packages/cli/src/cli.test.ts, packages/cli/package.json, packages/cli/src/cli.ts
Tests parse JSON from process.stdout.write, use platform-specific temporary directories, include CLI and stdout tests, and document explicit CLI termination.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related issues

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the main fix for truncated piped workflow JSON output.
Linked Issues check ✅ Passed The changes address issue #2384 by awaiting stdout completion, preserving JSON delivery through pipes, and adding regression coverage.
Out of Scope Changes check ✅ Passed The changes support reliable JSON output, shared stdout handling, regression coverage, and cross-platform CLI tests without unrelated code changes.
Docstring Coverage ✅ Passed Docstring coverage is 89.47% which is sufficient. The required threshold is 80.00%.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch archon/task-archon-fix-github-issue-experimental-1785738722065

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ESLint install failed: dependency version conflict. Check your lock file or package.json.


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.

@Wirasm

Wirasm commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator Author

⚡ Self-Fix Report (Aggressive)

Status: COMPLETE
Pushed: ✅ No fix commit was needed; the PR branch is already current
Philosophy: Fix everything unless clearly a new concern


Fixes Applied (0 total)

The review reported no actionable findings.


Tests Added

(none)


Skipped (0)

(none — all findings addressed)


Suggested Follow-up Issues

(none)


Validation

✅ Type check | ✅ Lint | ✅ Tests


Self-fix by Archon · aggressive mode · no changes required on archon/task-archon-fix-github-issue-experimental-1785738722065

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
packages/cli/src/commands/workflow.test.ts (1)

2342-2355: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Cover backpressure in the verbose workflowGetCommand path.

The get-command mock invokes the process.stdout.write callback immediately. The assertion at Line [2524] checks only the captured JSON. Add a test that holds the callback, verifies workflowGetCommand('run-v', true, true) remains pending, then releases the callback. The existing delayed-callback test covers only workflowStatusCommand.

Also applies to: 2524-2524

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/cli/src/commands/workflow.test.ts` around lines 2342 - 2355, Extend
the tests around workflowGetCommand to cover stdout backpressure: make the
process.stdout.write mock retain its callback, assert
workflowGetCommand('run-v', true, true) remains pending before releasing it,
then invoke the callback and verify completion and output. Keep the existing
workflowStatusCommand delayed-callback coverage unchanged.
packages/cli/src/cli.test.ts (1)

253-263: 📐 Maintainability & Code Quality | 🔵 Trivial | 🏗️ Heavy lift

Test the runtime lifecycle instead of source text.

This test reads cli.ts and checks string presence. It does not execute main() or verify that pending stdout completes before exit. Add a behavior-level test that controls stdout completion and checks the normal and fatal exit codes.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/cli/src/cli.test.ts` around lines 253 - 263, Replace the source-text
assertions in the “CLI process lifecycle” test with a runtime test that invokes
the CLI entrypoint, controls or mocks stdout completion, and verifies output is
allowed to flush before termination. Cover both the normal exit-code path and
the fatal-error path, asserting the resulting exit codes without relying on
string searches of cli.ts.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@packages/cli/src/cli.test.ts`:
- Around line 253-263: Replace the source-text assertions in the “CLI process
lifecycle” test with a runtime test that invokes the CLI entrypoint, controls or
mocks stdout completion, and verifies output is allowed to flush before
termination. Cover both the normal exit-code path and the fatal-error path,
asserting the resulting exit codes without relying on string searches of cli.ts.

In `@packages/cli/src/commands/workflow.test.ts`:
- Around line 2342-2355: Extend the tests around workflowGetCommand to cover
stdout backpressure: make the process.stdout.write mock retain its callback,
assert workflowGetCommand('run-v', true, true) remains pending before releasing
it, then invoke the callback and verify completion and output. Keep the existing
workflowStatusCommand delayed-callback coverage unchanged.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: a1c15f77-6a45-4d6e-bf8e-5bda41500c93

📥 Commits

Reviewing files that changed from the base of the PR and between 32b7fc7 and 634f1ba.

📒 Files selected for processing (4)
  • packages/cli/src/cli.test.ts
  • packages/cli/src/cli.ts
  • packages/cli/src/commands/workflow.test.ts
  • packages/cli/src/commands/workflow.ts

The previous attempt did not fix the bug. `workflow runs --json --all
--limit 200 | cat` still truncated at exactly 98,304 bytes with exit 0 in
5/5 runs, because that command was never one of the two call sites it
patched — and its tests mocked `process.stdout.write`, the very thing that
was broken, so they passed regardless.

Root cause: `@archon/paths` builds the pino root logger at module load,
whose default destination puts fd 1 into non-blocking mode. Every CLI
command imports it transitively. On a non-blocking pipe a write larger than
the pipe capacity returns a SHORT count, and `console.log` discards the
unwritten remainder without error. Verified directly: `fs.writeSync(1, ...)`
inside the CLI returns 65536-byte short writes and intermittently throws
EAGAIN. A regular-file fd stays blocking, which is why redirecting to a file
always looked fine.

The loss happens inside `console.log` at call time, not at process exit, so
no exit-path flush can recover it — confirmed by measuring an awaited
trailing `process.stdout.write('')`, which still truncated 8/8. That makes
the previous `process.exit()` -> `process.exitCode` change unnecessary, and
it carried a real hang risk if any command leaves a handle open, so it is
reverted.

Changes:
- Add src/utils/stdout.ts: writeStdout()/writeJsonLine() resolve only once
  every byte has reached the OS, retrying short writes and EAGAIN through
  the stream rather than busy-waiting.
- Route every CLI --json emitter through it (workflow list/status/get/runs/
  search/reset-sessions, the approve/reject/abandon/resume envelopes, the
  --detach ack, validate workflows/commands, ai tier/alias list). No
  machine-readable payload goes through console.log any more. Output bytes
  are unchanged, including the compact-vs-pretty formatting of each command.
- Restore explicit process.exit() so a lingering handle cannot hang the CLI.
- Replace the mock-based and source-grep tests with a real-pipe regression
  test that spawns the CLI through a genuine `| cat` shell pipeline and
  compares the result byte-for-byte against a file redirect, 10 runs per
  assertion, plus exit-code propagation. Verified to fail on the pre-fix
  code (98,304 vs 162,979 bytes) — Bun.spawn's own pipe does not reproduce
  the truncation, so a test built on it would have proved nothing.
- Wire src/cli.test.ts into the package test script; it was never run.

Measured on the reported repro (`workflow runs --json --all --limit 200`):
before 1/6 piped runs valid, after 12/12 valid and byte-identical to the
file redirect.

Fixes #2384

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/cli/src/utils/stdout.test.ts`:
- Around line 78-99: Change the stdout test fixture lifecycle from
beforeAll/afterAll to beforeEach/afterEach so each test receives a fresh scratch
directory and generated workflows, and cleanup runs after every test. Preserve
the existing fixture creation in beforeAll and removal logic in afterAll while
moving them to the per-test hooks.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: c68bdecf-3670-4f2a-9433-d084e5cfbb92

📥 Commits

Reviewing files that changed from the base of the PR and between 634f1ba and 7a2f24f.

📒 Files selected for processing (9)
  • packages/cli/package.json
  • packages/cli/src/cli.ts
  • packages/cli/src/commands/ai.test.ts
  • packages/cli/src/commands/ai.ts
  • packages/cli/src/commands/validate.ts
  • packages/cli/src/commands/workflow.test.ts
  • packages/cli/src/commands/workflow.ts
  • packages/cli/src/utils/stdout.test.ts
  • packages/cli/src/utils/stdout.ts

Comment on lines +78 to +99
beforeAll(() => {
scratch = mkdtempSync(join(tmpdir(), 'archon-pipe-test-'));
archonHome = join(scratch, 'home');
repoDir = join(scratch, 'repo');
mkdirSync(archonHome, { recursive: true });
const workflowsDir = join(repoDir, '.archon', 'workflows');
mkdirSync(workflowsDir, { recursive: true });
spawnSync('git', ['init', '-q', '.'], { cwd: repoDir });

const padding = 'padding '.repeat(DESCRIPTION_WORDS);
for (let i = 0; i < WORKFLOW_COUNT; i++) {
const name = `probe-${String(i).padStart(3, '0')}`;
writeFileSync(
join(workflowsDir, `${name}.yaml`),
`name: ${name}\ndescription: ${padding}${i}\nnodes:\n - id: only\n prompt: hello\n`
);
}
});

afterAll(() => {
if (scratch) rmSync(scratch, { recursive: true, force: true });
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Clean up the temporary fixture after each test.

beforeAll creates workflow files and pipeline output files that remain available to later tests. Use beforeEach and afterEach, or remove all generated files in afterEach, to isolate the tests.

As per coding guidelines, “clean up test data after each test.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/cli/src/utils/stdout.test.ts` around lines 78 - 99, Change the
stdout test fixture lifecycle from beforeAll/afterAll to beforeEach/afterEach so
each test receives a fresh scratch directory and generated workflows, and
cleanup runs after every test. Preserve the existing fixture creation in
beforeAll and removal logic in afterAll while moving them to the per-test hooks.

Source: Coding guidelines

Two assertions in cli.test.ts hardcode '/tmp', which does not exist on Windows —
findRepoRoot('/tmp') and existsSync('/tmp') both fail there.

Not a Windows defect in the CLI. This file was absent from the package's test
script until it was wired in alongside the stdout work, so the POSIX assumption
had never run in CI and the failure only appeared once the file started
executing. os.tmpdir() carries the same intent on every platform: a directory
that exists and is not inside a git repo.
@Wirasm

Wirasm commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed a4ed3230 — the Windows failures were test bugs, not a CLI defect.

Two assertions hardcoded /tmp:

const result = await git.findRepoRoot('/tmp');
expect(existsSync('/tmp')).toBe(true);

Neither holds on Windows, where that path does not exist. Both now use os.tmpdir(), which carries the same intent everywhere: a directory that exists and is not inside a git repo.

Worth noting why this surfaced now — packages/cli/src/cli.test.ts was not in the package's test script, so it had never run in CI (#2401). Wiring it in is what made a long-standing POSIX assumption visible. The file failing on its first real execution is the guard working, not a regression.

Verified: 50 pass / 0 fail locally, type-check and format clean.

@Wirasm

Wirasm commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator Author

On the beforeAll finding — not acting on it, reasoning recorded here.

The guideline's concern ("clean up test data after each test") is already satisfied: beforeAll creates a unique mkdtempSync(join(tmpdir(), 'archon-pipe-test-')) and afterAll removes it with rmSync(scratch, { recursive: true, force: true }). Nothing leaks.

The isolation risk worth checking would be tests sharing an output filename, where a truncated-to-zero run could silently read a previous test's good file — which for a truncation test would be the worst possible failure mode. They don't share:

  • reference.json — written once
  • piped-${run}.json — unique per iteration
  • sink.json — separate

Every write target is distinct, so a stale file cannot mask a failure.

Moving to beforeEach would make it worse: fixture setup creates a git repo and workflow files, and the piped test runs 10 iterations spawning real shell pipelines. Rebuilding per test costs time and buys no safety.

@Wirasm
Wirasm merged commit 7a7640d into dev Aug 3, 2026
4 checks passed
@Wirasm Wirasm mentioned this pull request Aug 4, 2026
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.

fix(cli): JSON output over a pipe silently truncates with exit 0 (bun EAGAIN drop)

1 participant