Skip to content

feat(core): guarantee terminal output files and add an agent-friendly summary output style - #36703

Draft
AgentEnder wants to merge 3 commits into
fix-nxc-4693-4692from
feat/nxc-4694-4698-agent-output
Draft

feat(core): guarantee terminal output files and add an agent-friendly summary output style#36703
AgentEnder wants to merge 3 commits into
fix-nxc-4693-4692from
feat/nxc-4694-4698-agent-output

Conversation

@AgentEnder

Copy link
Copy Markdown
Member

Note

Stacked on #36453 (base is fix-nxc-4693-4692, not master). Review the three commits above fb9627ee. I will retarget to master once #36453 merges.

Two tickets, in one PR at Jason's request — the second is meaningless without the first.

Current Behavior

Terminal output files are not guaranteed. The writeFileSync that persists task output lives only in the non-streaming single-task branch. Batch output arrives over IPC in memory and only lands on disk via cache.put, which is gated on task.cache. So a cache:false batch task — the main CI path under NX_BATCH_MODE — leaves no terminal output file, and anything pointing at <cacheDir>/terminalOutputs/<hash> dangles exactly there.

Agents read transcripts to find one failure. With the TUI off they get the static renderer and thousands of lines of passing output.

Expected Behavior

NXC-4694 — every task leaves its output on disk

Every task that reaches a terminal state writes to <cacheDir>/terminalOutputs/<hash>, regardless of cache setting, streaming mode, or batch membership. No double-write when cache.put also stores it.

Outputs written without a cache entry (uncacheable tasks, and cacheable ones under --skip-nx-cache) get a row via a new napi record_terminal_outputs, carrying has_artifacts = 0. Without a row the file is invisible to remove_old_cache_records, which only walks hashes it finds in the database, so these files would accumulate forever. The flag means such a row can never be served as a cache hit — there are no artifacts behind it.

Bonus interop: Nx Cloud's upload fallback already reads this exact path when in-memory output is undefined, so guaranteed files make that path real for streamed and continuous tasks.

NXC-4698--output-style=summary

Because the files are now guaranteed, a run no longer has to carry that text:

 NX   34 tasks: 31 succeeded, 29 cached, 1 failed, 2 skipped

✖  nx run js:test  (exit 3)
   full log: /repo/.nx/cache/terminalOutputs/9f2c…

Re-run with --output-style=static to inline logs.

Nothing is printed for a successful task, and no task output is inlined at all — so the size of a run's output depends on how many tasks failed, never on how much they logged.

It is the default when isAiAgent() and no style was given. That resolution sits in the existing yargs middleware chain, after NX_DEFAULT_OUTPUT_STYLE and before the TUI check, so an explicitly requested style always wins and the TUI correctly declines. Deciding it at life-cycle selection instead would have skipped NX_DEFAULT_OUTPUT_STYLE and landed after --output-style was already normalized.

Decisions worth reviewing

The summary points at the raw log, not a stripped-ANSI sibling. That keeps NXC-4696 out of scope. It only works because nothing is inlined — an inlined excerpt would carry escape codes.

An earlier cut inlined the last ~20 lines of each failure. It was dropped. The argument for it is one fewer round trip; the arguments against won: every consumer of this style can read the file (it is chosen for agents or asked for explicitly), a fixed tail is as likely to catch a runner's footer as its error, and with 8 failures a 21-line-each bound is a transcript again.

The inline fallback for an unhashed task was removed as unreachable. processTask hashes before it schedules, and the only task.hash = undefined in the codebase is a transient clear immediately followed by a re-hash — so a task with terminal output always has a hash. The remaining if (task.hash) is a type guard against printing a bogus path, and says so.

Verification

  • tsc clean on packages/nx and e2e/nx; 169 unit tests green; cargo check passes on the Rust change.
  • New e2e (e2e/nx/src/terminal-outputs.test.ts): cache:false under NX_BATCH_MODE and under --output-style=stream each leave a readable file; the cached replay path is unchanged; a --skip-nx-cache record is never served as a hit. Plus the summary cases — a passing run stays under 10 lines, a failing run under 30, an explicit style beats the agent default, and the printed path is opened and asserted to contain the task's output, which is the seam between the two tickets.
  • Not run locally: the e2e (needs a published local-registry build) and the native cache.spec.ts (the committed .node predates record_terminal_outputs). CI covers both.

Related Issue(s)

Tracked in Linear rather than as GitHub issues:

  • NXC-4694 — Always write terminalOutputs files (streamed, batch, cache:false)
  • NXC-4698--output-style=summary, auto-selected under isAiAgent()

Both belong to the Terminal Logs available from disk milestone of Agent Friendly Task Output. NXC-4694 unblocks NXC-4696 and NXC-4695; NXC-4698 unblocks NXC-4699.


View Polygraph session ↗

@nx-cloud

nx-cloud Bot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

View your CI Pipeline Execution ↗ for commit 55ddce4

Command Status Duration Result
nx affected --targets=lint,test,build,e2e,e2e-c... ⛔ Cancelled 1h 39m 6s View ↗
nx run-many -t check-imports check-lock-files c... ✅ Succeeded 3s View ↗
nx-cloud record -- pnpm nx-cloud conformance:check ✅ Succeeded 59s View ↗
nx build workspace-plugin ✅ Succeeded <1s View ↗
nx-cloud record -- nx sync:check ✅ Succeeded 18s View ↗
nx-cloud record -- nx format:check ✅ Succeeded 6s View ↗

☁️ Nx Cloud last updated this comment at 2026-08-21 20:00:29 UTC

@AgentEnder
AgentEnder force-pushed the feat/nxc-4694-4698-agent-output branch 4 times, most recently from 5eabbf2 to b69a54e Compare August 21, 2026 16:48
Every task that reaches a terminal state now writes its terminal output to
`<cacheDir>/terminalOutputs/<hash>`, whatever its `cache` setting, output
style, or batch membership. That path is what the static output styles point
users and agents at, and what Nx Cloud reads as a fallback when a task's
in-memory output is undefined, so a task finishing without a file there was a
dangling reference.

The gaps were:

- Batch tasks. Their output only ever arrives over IPC, so the sole thing that
  ever put it on disk was `cache.put`. A `cache:false` batch task — the shape
  CI runs under `NX_BATCH_MODE` — left nothing behind at all.
- Failed cacheable tasks. Failures aren't cached (without NX_CACHE_FAILURES),
  so nothing wrote them either — exactly the output an agent wants.
- Streamed `nx:run-commands` tasks run directly, whose write was gated on the
  same condition as the printing.
- Tasks whose fork threw before a process existed.

`postRunSteps` — the one place every discrete and batch result passes through
— now backstops all of them.

On the double write with `cache.put`: skip-if-same, resolved by construction
rather than by comparison. The set of results `cache.put` will write is now
computed up front (`resultsToCache`) and handed to the backstop, which skips
exactly those tasks. Making `put` reuse an already-written file would have
meant changing the napi `NxCache.put` signature, which Nx Cloud calls
directly. Cache replays are skipped for the same reason — the output they
replayed was read from that very file.

Tasks whose runner already owns the file (forked processes write it as they
exit; a continuous task's exit is its only chance) are recorded in
`tasksWithPersistedOutput` and skipped too, so each task's output is written
once. That also answers the old TODO on the run-commands exit handler: the
disk write is what a continuous task needs, the printing is not.

What lifecycles receive is unchanged — `TaskResult.terminalOutput` is still
populated for every status. This changes only what lands on disk.
`remove_old_cache_records` only ever walks hashes it finds in
`cache_outputs`, and only `put` writes rows there. Terminal outputs written
without a cache entry — uncacheable tasks, and cacheable ones run with
`--skip-nx-cache` — therefore had nothing pointing the GC at them and
accumulated in `<cacheDir>/terminalOutputs` forever. Guaranteeing those files
exist made that leak worse, so it is fixed here.

`cache_outputs` gains `has_artifacts`, and `NxCache.record_terminal_outputs`
registers a batch of hashes with `has_artifacts = 0`. The existing 7-day sweep
then collects them with no new deletion logic — it already unlinks both the
`<hash>` directory and the terminal output file.

The flag is what keeps a bookkeeping row from being replayed as a cache hit.
`get` and `fetch_cache_rows` filter on it, because a row alone used to mean
"cache hit": without the filter, a `--skip-nx-cache` run (whose hash is
unchanged by the flag, and which writes no artifacts) would leave a row that
the next ordinary run reports as a hit while restoring nothing. `put` forces
the flag back to 1 on conflict so a real entry always supersedes a
bookkeeping row, and the bookkeeping insert touches only `accessed_at` on
conflict so it can neither demote a real entry nor overwrite the size that
covers its artifacts. Updating `accessed_at` also keeps a task that is still
run daily from aging out and having its current output deleted underneath it.

Continuous tasks are registered as they exit, since they never reach
postRunSteps and their file is written by the runner.

Two adjacent fixes fall out of the same invariant:

- `ensure_cache_size_within_limit` deleted the row and the `<hash>` directory
  but not the terminal output file, stranding it with nothing left to point
  the GC at it. It now removes both, matching `remove_old_cache_records`.
- `check_cache_fs_in_sync` asks whether any cache records exist; that now
  means records with artifacts, since only those own a directory that can be
  out of sync with the filesystem.

DB_VERSION is bumped rather than migrating: the db path is version-scoped
(`{name}-v{DB_VERSION}.db`) and stale versions are reaped, so a bump gives a
fresh schema with no ALTER TABLE.
…rinting them

An agent driving nx gets the static renderer and reads thousands of lines of
passing output to find the one failure. Since NXC-4694 every task leaves its
output at `<cacheDir>/terminalOutputs/<hash>`, so the run itself no longer has
to carry that text.

`--output-style=summary` prints run counts and one line per failing task naming
the file holding its output:

     NX   34 tasks: 31 succeeded, 29 cached, 1 failed, 2 skipped

    x  nx run js:test  (exit 3)
       full log: /repo/.nx/cache/terminalOutputs/9f2c...

    Re-run with --output-style=static to inline logs.

Nothing is printed for a task that succeeded, and no task output is inlined at
all, so the size of a run's output depends on how many tasks failed and never on
how much they logged. Inlining a fixed tail was considered and dropped: every
consumer of this style can read the file - it is chosen for agents or asked for
explicitly - and a fixed slice is as likely to catch a runner's footer as its
error. The style that inlines logs is named in the closing line.

It is the default when `isAiAgent()` and no style was given. That resolution
sits in the existing yargs middleware chain, after NX_DEFAULT_OUTPUT_STYLE and
before the TUI check, so an explicitly requested style always wins and the TUI
correctly declines. Deciding it at life cycle selection instead would have
skipped NX_DEFAULT_OUTPUT_STYLE and landed after `--output-style` was
normalized.

`terminalOutputPathForHash` is shared from `tasks-runner/cache` so the renderer
can address a log without holding a cache instance; it mirrors the native
layout, which the legacy cache already agreed with.
@AgentEnder
AgentEnder force-pushed the feat/nxc-4694-4698-agent-output branch from b69a54e to 55ddce4 Compare August 21, 2026 18:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant