Skip to content

traj: formatted tail and cat return the steps --filter matches - #112

Merged
nickjalbert merged 1 commit into
laude-institute:mainfrom
MaxFreedomPollard:traj-filter-format
Sep 9, 2026
Merged

nickjalbert merged 1 commit into
laude-institute:mainfrom
MaxFreedomPollard:traj-filter-format

Conversation

@MaxFreedomPollard

@MaxFreedomPollard MaxFreedomPollard commented Sep 5, 2026 •

Copy link
Copy Markdown
Contributor

Follow-up to #110, same function.

What

In formatted mode — what a terminal gets by default — traj tail and
traj cat end the stream at the first step _format_line cannot turn into
six lines, exit 1, and say nothing. Two ways to hit that:

A filter. --filter type=thought at a terminal prints nothing at all,
because the first step of every trajectory is its header and no type filter
matches it. Widen the filter so the header does match and it becomes silent
truncation instead:

$ traj cat --filter type=trajectory,thought        # at a tty
2026/09/05 03:25:48 e0e9ef46 [trajectory] root
2026/09/05 03:25:48 a882d8db [thought] alpha
$ echo $?
1                                                    # gamma, also a thought, is gone

A step the preview cannot render. traj append takes JSON on stdin
(thinkers/monolith/prompt.md) and checks only that it parses, so .content
can be an object. The preview slices it, jq errors after one of its six
lines, and the stream dies there — -r included, because its pre-filter
passes the step. A malformed line does the same to a bare traj cat with no
flags. -a gets past all of these — it renders the object step and skips the
malformed line — and --raw returns every step; the default formatted view is
the one that dies, and -r dies with it on the unrenderable step.

tail -f --filter … is the sharpest form: the follower dies at the first
non-matching step and stays dead, with no diagnostic.

Why

_format_line hands each step to a jq program that begins with
select(matches_filters($filters)) and reads the six result lines back with a
{ read; read; … } < <(jq …) group. Whenever jq emits fewer than six lines —
a filtered-out step emits none, an unrenderable one emits one — a read fails
at EOF, and under the file's set -euo pipefail that kills the while read
subshell driving the stream. The guard meant to skip such a step,
[[ -n "$entry_type" ]] || return 0, is right after the group and is never
reached.

2c74855 states the invariant this breaks — "a single corrupt line skips
gracefully instead of aborting and dropping all subsequent entries"
— and
hardened every jq call that reads a trajectory file except this one, which is
handed a single line and cannot use fromjson?. 93ad7bb then added --filter,
which made the dead guard matter for every non-matching step rather than only
for corrupt lines.

The -a branch of the same function already does this right: capture jq's
output, || return 0, guard empty, then split. This makes the default branch
do the same.

The fix

-    local entry_type content source ts step_id traj_hex
-    { read -r entry_type
-      …
-    } < <(
-        printf '%s' "$line" | jq -r … '
+    local _fields
+    _fields=$(printf '%s' "$line" | jq -r … '
             …
-        ' 2>/dev/null
-    )
+        ' 2>/dev/null) || return 0
+    [[ -n "$_fields" ]] || return 0
+
+    local entry_type content source ts step_id traj_hex
+    { read -r entry_type
+      …
+    } <<< "$_fields" || true
     [[ -n "$entry_type" ]] || return 0

The jq program is unchanged. Two details worth a sentence each:

  • Why capture rather than || true on the old group. A one-token
    || true after the process substitution also stops the deaths and passes
    every filter case — but on an unrenderable step it reads the one line jq
    did emit and prints a fabricated ----/--/-- --:--:-- [thought] line with
    a blank id. Capturing first lets || return 0 see jq's exit status and skip
    the step. The test pins the difference.
  • Why || true on the new group. $(…) drops trailing newlines, so a
    record whose last field is the empty string comes back a line short. The
    process substitution kept that newline and read the field as empty; this
    keeps doing so instead of failing on the sixth read.

_format_line is reached only in formatted mode. Every scripted traj call
in the repo passes --raw and is untouched. One automated caller does run
formatted: the TUI sets CLICOLOR_FORCE=1 on every traj it spawns
(tui/headlong/src/main.rs:341) and auto-refreshes with tail -r -n 50. For
any tree that view renders today its output is byte-identical; where it
used to die on an unrenderable step and show a truncated tree, it now shows
the rest. A tail --filter … typed into its /traj pane hits this bug today.
Output for every step that rendered before is unchanged; two small
differences, both matching the -a branch: a step whose preview jq errors is
now skipped rather than ending the stream, and on bash ≥ 4.4 a NUL byte in a
step's content now draws bash's "ignored null byte" warning on stderr, as it
already does under -a.

Tests

tests/test_traj_formatted_filter.sh pins, in formatted mode: cat and
tail with a type filter exit 0 and print the matching steps; formatted and
raw modes select the same step ids (raw is the oracle); a filter only the
header matches prints one line and exits 0 — the first non-match no longer
ends the stream; a filter nothing matches prints nothing and exits 0; two
--filter flags AND and comma values OR; the -a and raw neighbours are
unchanged; a formatted line still carries stamp, id, [type, source] and
content in that order, so the six reads stay matched to the six lines jq
emits; -r still prefixes the id with the file hex; a malformed line no
longer truncates the stream and leaks nothing to stderr; a record with an
empty trailing field is shown; and a step jq cannot render is skipped with no
fabricated line in its place.

On main: 9 passed, 17 failed — the nine are the neighbour and rendering
controls. With the fix: 26 passed, 0 failed. The one-token alternative above:
25 passed, 1 failed, on the fabricated-line check. Mutation-tested against
fourteen deliberate breakages of bin/traj — reverting to the process
substitution, dropping the capture's || return 0, inverting the empty guard,
dropping the read group's || true, ignoring the filter, printing only the
first match, hardcoding the type, reordering or swapping jq fields, dropping a
read, breaking the timestamp branch, leaking jq's stderr, corrupting the
content column — thirteen are caught. The fourteenth, deleting
[[ -n "$_fields" ]] || return 0, is not observable because the read group
tolerates a short record and the entry_type guard then returns; the line
stays because it mirrors the -a branch and says what is meant.

Checked locally: shellcheck -S warning clean over the CI file set, the new
test clean at -S info too. Parses and runs under Apple's bash 3.2 and a pure
BSD userland as well as GNU coreutils; stable across timezones and locales.
cloc bin/ thinkers/ +1 code line. tests/run-all.sh green apart from two
failures that are pre-existing on main here and green in CI:
test_persona_chat_exit.sh (pins PATH to /usr/bin:/bin, which has no jq
on a Homebrew Mac) and test_workspace_runtime.sh (_runtime_line stats
.git/HEAD, which does not exist when the checkout is a git worktree, as mine
is).

In formatted mode — the default on a tty — _format_line hands each step to a
jq program that begins with select(matches_filters($filters)) and reads the
six result lines back with a `{ read; read; ... } < <(jq ...)` group.
Whenever jq emits fewer than six lines, a read fails at EOF and under the
file's `set -e` that kills the `while read` subshell driving the stream. The
guard meant to skip such a step, `[[ -n "$entry_type" ]] || return 0`, sits
right after the group and was never reached.

A filtered-out step emits nothing, so `traj tail --filter type=thought` at a
terminal printed nothing and exited 1: the first line of every trajectory is
its header, which no type filter matches. A filter the header does match
turned it into silent truncation instead, the stream stopping at the first
non-matching step with matching ones still to come. A step the preview
cannot render — `traj append` takes JSON on stdin and checks only that it
parses, so .content can be an object — emits one line, and ended the stream
on every formatted path, -r included, since its pre-filter passes the step.
A malformed line did the same to a bare `traj cat`. 2c74855 states the
invariant this breaks and hardened every jq call that reads a trajectory
file except this one, which is handed a single line; 93ad7bb then added
--filter, which made the dead guard matter for every non-matching step.

The -a branch of the same function already does it right: capture jq's
output, return on failure or empty, then split. Do the same here. Capturing
rather than putting `|| true` on the old group matters for the unrenderable
step: `|| true` reads the one line jq did emit and prints a fabricated line
with a blank stamp and id, while the capture lets `|| return 0` see the
failure and skip the step. One difference from the process substitution has
to be kept: the capture drops trailing newlines, so a record whose last
field is the empty string arrives a line short; the old read saw that field
as empty, and the read group is allowed to come up short so it still does.

Raw mode filters correctly and is untouched, so every scripted caller in the
repo is unaffected. The TUI runs traj formatted (CLICOLOR_FORCE=1): its
auto-refresh `tail -r -n 50` renders the same bytes for any tree it renders
today and no longer stops short at an unrenderable step, and a `tail
--filter` typed into its /traj pane hits this today.

tests/test_traj_formatted_filter.sh pins: cat and tail with a type filter
exit 0 and print the matching steps; formatted and raw modes select the same
step ids; a filter only the header matches prints one line and exits 0; a
filter nothing matches prints nothing and exits 0; two --filter flags AND
and comma values OR; the -a and raw neighbours are unchanged; a formatted
line still carries stamp, id, type, source and content in that order, so the
six reads stay matched to the six lines jq emits; -r still prefixes the id
with the file hex; a malformed line no longer truncates the stream and leaks
nothing to stderr; a record with an empty trailing field is shown; and a
step jq cannot render is skipped with no fabricated line in its place.
@nickjalbert

Copy link
Copy Markdown
Contributor

LGTM; thanks!!

@nickjalbert
nickjalbert merged commit f83519f into laude-institute:main Sep 9, 2026
12 checks passed
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.

2 participants