From 852b240ba02f88fe33483c1d47536a84e6dde904 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Tue, 3 Nov 2026 13:54:20 -0500 Subject: [PATCH 1/3] feat(corrections): share the local findings buffer across a clone's worktrees The local-file adapter now stores rows in findings-buffer/findings.jsonl under the git common directory, which every worktree of a clone shares, so a row noted in one worktree is visible to a retro in any other. The symlink and realpath guards and the append lock move with it. read.sh also returns rows from the old per-worktree .work/buffer/findings.jsonl. Closes #12907 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MWmASLCzgJayUQq9Saq4AK --- ...buffer-shared-across-worktrees-309060e9.md | 7 + plugins/corrections/README.md | 4 +- .../tools/findings-buffer/CONTRACT.md | 3 +- .../adapters/local-file/append.sh | 25 ++- .../adapters/local-file/read.sh | 32 ++-- .../tools/findings-buffer/buffer.sh | 2 +- .../tools/findings-buffer/buffer.test.sh | 34 +++- .../tools/findings-buffer/conformance.test.sh | 179 ++++++++++++------ 8 files changed, 192 insertions(+), 94 deletions(-) create mode 100644 .changes/corrections/feat-findings-buffer-shared-across-worktrees-309060e9.md diff --git a/.changes/corrections/feat-findings-buffer-shared-across-worktrees-309060e9.md b/.changes/corrections/feat-findings-buffer-shared-across-worktrees-309060e9.md new file mode 100644 index 00000000000..f4ef36cbbc7 --- /dev/null +++ b/.changes/corrections/feat-findings-buffer-shared-across-worktrees-309060e9.md @@ -0,0 +1,7 @@ +--- +bump: minor +--- + +### Changed + +- The `local-file` findings buffer moves to `findings-buffer/findings.jsonl` under the git common directory, so every worktree of a clone reads and writes one file; `read.sh` still returns rows from the old per-worktree `.work/buffer/findings.jsonl` (#12907). diff --git a/plugins/corrections/README.md b/plugins/corrections/README.md index 5d8f312b31a..185d1860044 100644 --- a/plugins/corrections/README.md +++ b/plugins/corrections/README.md @@ -59,8 +59,8 @@ Run `scripts/prove-check.test.sh` to exercise it against a fixture repository. ## Configuration -`note` writes findings-buffer rows, under `.work/buffer/` at the repository root by default, -gitignored. No network. +`note` writes findings-buffer rows, by default to `findings-buffer/` in the git common directory: +one file per clone, shared by its worktrees, never committed. No network. The two `findings_buffer_adapter_*` options bind one repository's own findings-buffer adapter, in user scope, where a repository cannot write. `buffer.sh` reads them from your user settings and diff --git a/plugins/corrections/tools/findings-buffer/CONTRACT.md b/plugins/corrections/tools/findings-buffer/CONTRACT.md index 7b847933e28..42a5115a68e 100644 --- a/plugins/corrections/tools/findings-buffer/CONTRACT.md +++ b/plugins/corrections/tools/findings-buffer/CONTRACT.md @@ -86,6 +86,7 @@ adapter whose MAJOR differs from its own, and tolerates a higher MINOR. A missin Rows derived from session transcripts never leave the machine. An adapter that stores rows off the machine (a hosted sink) must not receive them; only the `local-file` adapter may hold such rows. +It keeps one file per clone, shared by the clone's worktrees and never committed or pushed. `buffer.sh` does not route rows by origin yet: it hands every row to the bound adapter, so bind only an adapter that keeps rows on the machine until routing to a hosted sink exists. @@ -93,7 +94,7 @@ only an adapter that keeps rows on the machine until routing to a hosted sink ex | Adapter | Storage | |---|---| -| `local-file` | `.work/buffer/findings.jsonl` under the repository root, created on first append together with a `.work/buffer/.gitignore` holding `*`, so rows never reach git. Both verbs refuse (exit `2`, nothing stored) when `.work`, `.work/buffer`, the lock or `findings.jsonl` is a symlink, or the buffer resolves outside the repository. Appends take an exclusive lock, so concurrent calls never interleave or lose a row: `flock` on `.work/buffer/.lock`; where `flock` is absent (macOS), perl's `flock` on the same file when perl calls flock(2) itself (`$Config{d_flock}`); where neither is available, a `.work/buffer/.lockdir` directory holding the holder's pid. A flock(2) lock ends when every process holding it has exited, so an appender killed with SIGKILL holds up no later append. The holder removes the `.lockdir` directory on exit, and on HUP, INT or TERM, while its pid file still holds the holder's pid. That directory has no automatic takeover: on a host with neither, an appender killed with SIGKILL (or a power loss) leaves it behind, and every later append exits `1` naming it (at once when its pid is no longer running) until an operator removes it. Needs `jq`. Exits `3` outside a git repository or without `jq`. | +| `local-file` | `findings-buffer/findings.jsonl` under the git common directory (`git rev-parse --path-format=absolute --git-common-dir`), created on first append. Every worktree of one clone shares that directory, and git tracks nothing in it, so rows never reach a commit. `read.sh` also returns the rows of `.work/buffer/findings.jsonl` under the current worktree's root, where appends stored them before; `buffer.sh read` drops a repeat `obs_id`. Both verbs refuse (exit `2`, nothing stored) when `findings-buffer`, the lock or `findings.jsonl` is a symlink, and `read.sh` when `.work`, `.work/buffer` or the old `findings.jsonl` is; `append.sh` also refuses when the buffer resolves outside the git common directory. Appends take an exclusive lock, held across worktrees, so concurrent calls never interleave or lose a row: `flock` on `findings-buffer/.lock`; where `flock` is absent (macOS), perl's `flock` on the same file when perl calls flock(2) itself (`$Config{d_flock}`); where neither is available, a `findings-buffer/.lockdir` directory holding the holder's pid. A flock(2) lock ends when every process holding it has exited, so an appender killed with SIGKILL holds up no later append. The holder removes the `.lockdir` directory on exit, and on HUP, INT or TERM, while its pid file still holds the holder's pid. That directory has no automatic takeover: on a host with neither, an appender killed with SIGKILL (or a power loss) leaves it behind, and every later append exits `1` naming it (at once when its pid is no longer running) until an operator removes it. Needs `jq`. Exits `3` outside a git repository or without `jq`. | ## Core diff --git a/plugins/corrections/tools/findings-buffer/adapters/local-file/append.sh b/plugins/corrections/tools/findings-buffer/adapters/local-file/append.sh index 2c367d5a427..4c9e5cafd55 100755 --- a/plugins/corrections/tools/findings-buffer/adapters/local-file/append.sh +++ b/plugins/corrections/tools/findings-buffer/adapters/local-file/append.sh @@ -1,6 +1,7 @@ #!/usr/bin/env bash # local-file findings buffer: append.sh < rows.jsonl -# Stores each row in .work/buffer/findings.jsonl under the repository root. +# Stores each row in findings-buffer/findings.jsonl under the git common directory, +# which every worktree of a clone shares. # Exit: 0 every row durable, 1 storage failure, 2 usage, 3 not in a repository or jq missing. set -uo pipefail @@ -14,12 +15,12 @@ command -v jq >/dev/null 2>&1 || { exit 3 } -ROOT="$(git rev-parse --show-toplevel 2>/dev/null)" || { +COMMON="$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null)" && [[ -d "$COMMON" ]] || { echo "append.sh: not inside a git repository" >&2 exit 3 } -DIR="$ROOT/.work/buffer" +DIR="$COMMON/findings-buffer" FILE="$DIR/findings.jsonl" LOCKDIR="$DIR/.lockdir" HELD=0 @@ -43,25 +44,23 @@ fi [[ -s "$TMP" ]] || exit 0 -# refuse_links: the buffer stays a plain directory inside the repository, so a -# symlink can never redirect rows (or the lock file) elsewhere. +# refuse_links: the buffer stays a plain directory inside the git common directory, +# so a symlink can never redirect rows (or the lock file) elsewhere. Git tracks +# nothing there, so the rows never reach a commit. refuse_links() { local p - for p in "$ROOT/.work" "$DIR" "$DIR/.gitignore" "$DIR/.lock" "$LOCKDIR" "$FILE"; do + for p in "$DIR" "$DIR/.lock" "$LOCKDIR" "$FILE"; do if [[ -L "$p" ]]; then - echo "append.sh: $p is a symlink; the buffer must be a plain directory in the repository" >&2 + echo "append.sh: $p is a symlink; the buffer must be a plain directory in the git common directory" >&2 return 1 fi done } refuse_links || exit 2 mkdir -p "$DIR" || exit 1 -# Rows never leave the machine: keep the buffer out of git, keyed on the file so an -# interrupted or concurrent first append still ends with it in place. -[[ -f "$DIR/.gitignore" ]] || printf '*\n' >"$DIR/.gitignore" || exit 1 -REAL_DIR="$(cd "$DIR" && pwd -P)" && REAL_ROOT="$(cd "$ROOT" && pwd -P)" || exit 1 -if [[ "$REAL_DIR" != "$REAL_ROOT/.work/buffer" ]]; then - echo "append.sh: $DIR resolves outside the repository" >&2 +REAL_DIR="$(cd "$DIR" && pwd -P)" && REAL_COMMON="$(cd "$COMMON" && pwd -P)" || exit 1 +if [[ "$REAL_DIR" != "$REAL_COMMON/findings-buffer" ]]; then + echo "append.sh: $DIR resolves outside the git common directory" >&2 exit 2 fi diff --git a/plugins/corrections/tools/findings-buffer/adapters/local-file/read.sh b/plugins/corrections/tools/findings-buffer/adapters/local-file/read.sh index 16cef601de3..c14d4c6c91a 100755 --- a/plugins/corrections/tools/findings-buffer/adapters/local-file/read.sh +++ b/plugins/corrections/tools/findings-buffer/adapters/local-file/read.sh @@ -27,22 +27,32 @@ if [[ -n "$SINCE" && ! "$SINCE" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2} exit 2 fi -ROOT="$(git rev-parse --show-toplevel 2>/dev/null)" || { +COMMON="$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null)" && [[ -d "$COMMON" ]] || { echo "read.sh: not inside a git repository" >&2 exit 3 } -FILE="$ROOT/.work/buffer/findings.jsonl" -for p in "$ROOT/.work" "$ROOT/.work/buffer" "$FILE"; do - if [[ -L "$p" ]]; then - echo "read.sh: $p is a symlink; the buffer must be a plain directory in the repository" >&2 - exit 2 - fi -done -[[ -f "$FILE" ]] || exit 0 +# The shared file, then the per-worktree file appends wrote before the buffer moved. +FILES=() +refuse_links() { + local p + for p in "$@"; do + if [[ -L "$p" ]]; then + echo "read.sh: $p is a symlink; the buffer must be a plain directory" >&2 + exit 2 + fi + done +} +refuse_links "$COMMON/findings-buffer" "$COMMON/findings-buffer/findings.jsonl" +[[ -f "$COMMON/findings-buffer/findings.jsonl" ]] && FILES+=("$COMMON/findings-buffer/findings.jsonl") +if TOP="$(git rev-parse --show-toplevel 2>/dev/null)" && [[ -n "$TOP" ]]; then + refuse_links "$TOP/.work" "$TOP/.work/buffer" "$TOP/.work/buffer/findings.jsonl" + [[ -f "$TOP/.work/buffer/findings.jsonl" ]] && FILES+=("$TOP/.work/buffer/findings.jsonl") +fi +[[ ${#FILES[@]} -gt 0 ]] || exit 0 if [[ -z "$SINCE" ]]; then - cat "$FILE" || exit 1 + cat "${FILES[@]}" || exit 1 exit 0 fi @@ -55,4 +65,4 @@ command -v jq >/dev/null 2>&1 || { # a bare "Z", and the contract allows returning rows older than --since. A row with # no string ts is kept. jq -c --arg s "${SINCE:0:19}" \ - 'select((.ts | type) != "string" or .ts[0:19] >= $s)' "$FILE" || exit 1 + 'select((.ts | type) != "string" or .ts[0:19] >= $s)' "${FILES[@]}" || exit 1 diff --git a/plugins/corrections/tools/findings-buffer/buffer.sh b/plugins/corrections/tools/findings-buffer/buffer.sh index cd2fc658bb3..8d7b94dabe1 100755 --- a/plugins/corrections/tools/findings-buffer/buffer.sh +++ b/plugins/corrections/tools/findings-buffer/buffer.sh @@ -85,5 +85,5 @@ RC=$? if [[ $RC -eq 0 || "$ADAPTER" == "$LOCAL" ]]; then exit $RC fi -echo "buffer.sh: adapter $ADAPTER failed to append (exit $RC); appending to local-file (.work/buffer/) instead" >&2 +echo "buffer.sh: adapter $ADAPTER failed to append (exit $RC); appending to local-file instead" >&2 "$LOCAL/append.sh" <"$TMP/rows" diff --git a/plugins/corrections/tools/findings-buffer/buffer.test.sh b/plugins/corrections/tools/findings-buffer/buffer.test.sh index 51cbc8263b3..02ebe0e6e42 100755 --- a/plugins/corrections/tools/findings-buffer/buffer.test.sh +++ b/plugins/corrections/tools/findings-buffer/buffer.test.sh @@ -89,9 +89,11 @@ run() { OUT="$(cat "$TEST_TMPDIR/out")" ERR="$(cat "$TEST_TMPDIR/err")" } +# local_file : the local-file adapter's file, under the repository's git common directory. +local_file() { printf '%s/findings-buffer/findings.jsonl' "$(git -C "$1" rev-parse --path-format=absolute --git-common-dir)"; } # used_local : the append succeeded into the local file and said so. -used_local() { [[ $1 -eq 0 && -s "$2/.work/buffer/findings.jsonl" ]] && grep -q "local-file" <<<"$3"; } -local_rows() { cat "$1/.work/buffer/findings.jsonl" 2>/dev/null; } +used_local() { [[ $1 -eq 0 && -s "$(local_file "$2")" ]] && grep -q "local-file" <<<"$3"; } +local_rows() { cat "$(local_file "$1")" 2>/dev/null; } ROW='{"kind":"lint","session":"s1","msg":"unused variable"}' @@ -101,7 +103,7 @@ run "$R" append < <(printf '%s\n' "$ROW") DETAIL="exit $RC; stderr: $ERR" check "no binding: append exits 0" test "$RC" -eq 0 DETAIL="local rows: $(local_rows "$R")" -GOT="$(jq -r 'select(.msg == "unused variable") | (.obs_id | test("^[0-9a-f]{16}$")) and (.ts | type == "string")' "$R/.work/buffer/findings.jsonl" 2>/dev/null)" +GOT="$(jq -r 'select(.msg == "unused variable") | (.obs_id | test("^[0-9a-f]{16}$")) and (.ts | type == "string")' "$R/.git/findings-buffer/findings.jsonl" 2>/dev/null)" check "no binding: the row lands in the local file with obs_id and ts" test "$GOT" == true DETAIL="stderr: $ERR" check "no binding: stderr names local-file" grep -q "local-file" <<<"$ERR" @@ -114,7 +116,7 @@ run "$R" append < <(printf '%s\n' "$ROW") DETAIL="exit $RC; stderr: $ERR" check "failing adapter: append still exits 0" test "$RC" -eq 0 DETAIL="local rows: $(local_rows "$R")" -check "failing adapter: the row is in the local file" grep -q '"unused variable"' "$R/.work/buffer/findings.jsonl" +check "failing adapter: the row is in the local file" grep -q '"unused variable"' "$R/.git/findings-buffer/findings.jsonl" run "$R" read DETAIL="exit $RC; out: $OUT" check "failing adapter: read merges the adapter and fallback rows, once per obs_id" \ @@ -198,7 +200,7 @@ PATH="$SHIM:$PATH" run "$R" append < <(printf '%s\n' "$ROW") DETAIL="exit $RC; stderr: $ERR" check "redaction failure: append exits non-zero" test "$RC" -ne 0 check "redaction failure: nothing reaches the adapter or the local file" \ - test ! -e "$ADAPTERS/ok/captured.jsonl" -a ! -e "$R/.work/buffer/findings.jsonl" + test ! -e "$ADAPTERS/ok/captured.jsonl" -a ! -e "$R/.git/findings-buffer/findings.jsonl" # 7. A committed binding never runs repository code: only a plugin adapter name is accepted. new_repo @@ -243,7 +245,7 @@ run "$A" append < <(printf '%s\n' "$ROW") DETAIL="exit $RC; stderr: $ERR" check "user-scope binding: append exits 0" test "$RC" -eq 0 check "user-scope binding: the repository's adapter runs" grep -q '"unused variable"' "$A/tools/mine/captured.jsonl" -check "user-scope binding: nothing lands in the local file" test ! -e "$A/.work/buffer/findings.jsonl" +check "user-scope binding: nothing lands in the local file" test ! -e "$A/.git/findings-buffer/findings.jsonl" new_repo B="$R" @@ -355,12 +357,30 @@ bind "$R" broken run "$R" append < <(printf '%s\n' "$ROW") old_row() { printf '{"ts":"2020-01-01T00:00:00Z","session":"s1","msg":"%s","obs_id":"%s"}\n' "$1" "$2"; } old_row "old adapter row" aaaaaaaaaaaaaaaa >>"$ADAPTERS/broken/captured.jsonl" -old_row "old local row" bbbbbbbbbbbbbbbb >>"$R/.work/buffer/findings.jsonl" +old_row "old local row" bbbbbbbbbbbbbbbb >>"$R/.git/findings-buffer/findings.jsonl" run "$R" read --since 2025-01-01T00:00:00Z DETAIL="exit $RC; out: $OUT" check "read --since: both reads leave out rows older than since and keep the new one" \ test "$RC" -eq 0 -a "$(grep -c 'old .* row' <<<"$OUT")" -eq 0 -a "$(grep -c '"unused variable"' <<<"$OUT")" -eq 1 +# Worktrees of one clone share the local file, and a row also left in the old per-worktree file reads once. +new_repo +git -C "$R" -c user.name=fixture -c user.email=fixture@example.invalid -c commit.gpgsign=false commit -q --allow-empty -m fixture +WT="$TEST_TMPDIR/worktree-of-repo$N" +git -C "$R" worktree add -q -b shared "$WT" +run "$WT" append < <(printf '{"obs_id":"1111111111111111","msg":"noted in a worktree"}\n') +run "$R" read +DETAIL="exit $RC; out: $OUT" +check "worktrees: a row appended in a linked worktree reads back in the main worktree" \ + test "$RC" -eq 0 -a "$(grep -c '"noted in a worktree"' <<<"$OUT")" -eq 1 +mkdir -p "$R/.work/buffer" +printf '{"obs_id":"1111111111111111","ts":"2026-10-21T00:00:00Z","msg":"noted in a worktree"}\n' >"$R/.work/buffer/findings.jsonl" +printf '{"obs_id":"2222222222222222","ts":"2026-10-21T00:00:00Z","msg":"noted before the move"}\n' >>"$R/.work/buffer/findings.jsonl" +run "$R" read +DETAIL="exit $RC; out: $OUT" +check "worktrees: legacy rows read back, and a repeat obs_id reads once" \ + test "$RC" -eq 0 -a "$(grep -c '"noted in a worktree"' <<<"$OUT")" -eq 1 -a "$(grep -c '"noted before the move"' <<<"$OUT")" -eq 1 + printf '1..%d\n' "$CASE_NUM" if [[ $FAILED -gt 0 ]]; then echo "# $FAILED of $CASE_NUM failed" diff --git a/plugins/corrections/tools/findings-buffer/conformance.test.sh b/plugins/corrections/tools/findings-buffer/conformance.test.sh index 4cd458dcf5f..e8b33fa9c2a 100755 --- a/plugins/corrections/tools/findings-buffer/conformance.test.sh +++ b/plugins/corrections/tools/findings-buffer/conformance.test.sh @@ -66,9 +66,9 @@ make_broken() { D="$(make_broken drops)" cat >"$D/append.sh" <<'EOF' #!/usr/bin/env bash -ROOT="$(git rev-parse --show-toplevel)" || exit 3 -mkdir -p "$ROOT/.work/buffer" -head -n 1 >>"$ROOT/.work/buffer/findings.jsonl" +COMMON="$(git rev-parse --path-format=absolute --git-common-dir)" || exit 3 +mkdir -p "$COMMON/findings-buffer" +head -n 1 >>"$COMMON/findings-buffer/findings.jsonl" EOF bash "$CONF" "$D" >/dev/null 2>&1 expect_exit 1 $? "an adapter that loses a row fails" @@ -89,27 +89,27 @@ expect_exit 1 $? "an unparsable capabilities.json fails" D="$(make_broken lax)" cat >"$D/read.sh" <<'EOF' #!/usr/bin/env bash -ROOT="$(git rev-parse --show-toplevel)" || exit 3 -cat "$ROOT/.work/buffer/findings.jsonl" 2>/dev/null +COMMON="$(git rev-parse --path-format=absolute --git-common-dir)" || exit 3 +cat "$COMMON/findings-buffer/findings.jsonl" 2>/dev/null exit 0 EOF bash "$CONF" "$D" >/dev/null 2>&1 expect_exit 1 $? "an adapter that accepts a bad argument fails" -# local-file stores under .work/buffer/findings.jsonl +# local-file stores under /findings-buffer/findings.jsonl REPO="$TEST_TMPDIR/repo" git init -q -b main "$REPO" >/dev/null 2>&1 printf '{"obs_id":"x","ts":"2026-10-21T00:00:00Z"}\n' | (cd "$REPO" && bash "$LOCAL/append.sh") -if [[ -s "$REPO/.work/buffer/findings.jsonl" ]]; then - pass "local-file creates .work/buffer/findings.jsonl on first append" +if [[ -s "$REPO/.git/findings-buffer/findings.jsonl" ]]; then + pass "local-file creates .git/findings-buffer/findings.jsonl on first append" else - fail "local-file creates .work/buffer/findings.jsonl on first append" + fail "local-file creates .git/findings-buffer/findings.jsonl on first append" fi # a malformed row stores nothing and exits 2 printf '{"obs_id":"y","ts":"2026-10-21T00:00:01Z"}\nnot json\n' | (cd "$REPO" && bash "$LOCAL/append.sh" >/dev/null 2>&1) expect_exit 2 $? "append.sh with a malformed row exits 2" -if [[ "$(wc -l <"$REPO/.work/buffer/findings.jsonl")" -eq 1 ]]; then +if [[ "$(wc -l <"$REPO/.git/findings-buffer/findings.jsonl")" -eq 1 ]]; then pass "a malformed row stores nothing from that call" else fail "a malformed row stores nothing from that call" @@ -120,7 +120,7 @@ for bad in '{oops}' '{}' '{"obs_id":"z"}' '{"obs_id":"z","ts":"2026-10-21T00:00: printf '%s\n' "$bad" | (cd "$REPO" && bash "$LOCAL/append.sh" >/dev/null 2>&1) expect_exit 2 $? "append.sh rejects $bad" done -if [[ "$(wc -l <"$REPO/.work/buffer/findings.jsonl")" -eq 1 ]]; then +if [[ "$(wc -l <"$REPO/.git/findings-buffer/findings.jsonl")" -eq 1 ]]; then pass "rejected rows store nothing" else fail "rejected rows store nothing" @@ -145,7 +145,7 @@ done nf_append() { (cd "$REPO" && PATH="$NOFLOCK" bash "$LOCAL/append.sh"); } printf '{"obs_id":"nf1","ts":"2026-10-21T00:00:03Z"}\n' | nf_append expect_exit 0 $? "append.sh without flock exits 0" -if [[ ! -e "$REPO/.work/buffer/.lockdir" ]]; then +if [[ ! -e "$REPO/.git/findings-buffer/.lockdir" ]]; then pass "the mkdir lock is released after an append" else fail "the mkdir lock is released after an append" @@ -153,19 +153,19 @@ fi # TERM while waiting on a live holder: the waiter never held the lock, so the # holder's lock stays; TERM to the holder side is the trap path below. -mkdir "$REPO/.work/buffer/.lockdir" -printf '%s\n' "$$" >"$REPO/.work/buffer/.lockdir/pid" +mkdir "$REPO/.git/findings-buffer/.lockdir" +printf '%s\n' "$$" >"$REPO/.git/findings-buffer/.lockdir/pid" (cd "$REPO" && PATH="$NOFLOCK" exec bash "$LOCAL/append.sh" <<<'{"obs_id":"nf-wait","ts":"2026-10-21T00:00:05Z"}') 2>/dev/null & waiter=$! sleep 0.5 kill -TERM "$waiter" 2>/dev/null wait "$waiter" 2>/dev/null -if [[ "$(cat "$REPO/.work/buffer/.lockdir/pid" 2>/dev/null)" == "$$" ]]; then +if [[ "$(cat "$REPO/.git/findings-buffer/.lockdir/pid" 2>/dev/null)" == "$$" ]]; then pass "a TERMed waiter leaves another holder's lock in place" else fail "a TERMed waiter leaves another holder's lock in place" fi -rm -rf "$REPO/.work/buffer/.lockdir" +rm -rf "$REPO/.git/findings-buffer/.lockdir" # TERM to a holder: a slow sync stub keeps the holder inside the lock; bash runs # the TERM trap once the stub returns, and the EXIT trap releases the lock. @@ -178,34 +178,34 @@ chmod +x "$SLOW/sync" (cd "$REPO" && PATH="$SLOW" exec bash "$LOCAL/append.sh" <<<'{"obs_id":"nf-term","ts":"2026-10-21T00:00:06Z"}') 2>/dev/null & holder=$! for _ in $(seq 1 50); do - [[ -f "$REPO/.work/buffer/.lockdir/pid" ]] && break + [[ -f "$REPO/.git/findings-buffer/.lockdir/pid" ]] && break sleep 0.1 done kill -TERM "$holder" 2>/dev/null wait "$holder" 2>/dev/null -if [[ ! -e "$REPO/.work/buffer/.lockdir" ]]; then +if [[ ! -e "$REPO/.git/findings-buffer/.lockdir" ]]; then pass "the mkdir lock is released when its holder gets TERM" else fail "the mkdir lock is released when its holder gets TERM" - rm -rf "$REPO/.work/buffer/.lockdir" + rm -rf "$REPO/.git/findings-buffer/.lockdir" fi sleep 0 & dead=$! wait "$dead" -mkdir "$REPO/.work/buffer/.lockdir" -printf '%s\n' "$dead" >"$REPO/.work/buffer/.lockdir/pid" -before="$(wc -l <"$REPO/.work/buffer/findings.jsonl")" +mkdir "$REPO/.git/findings-buffer/.lockdir" +printf '%s\n' "$dead" >"$REPO/.git/findings-buffer/.lockdir/pid" +before="$(wc -l <"$REPO/.git/findings-buffer/findings.jsonl")" start=$SECONDS ERR="$(printf '{"obs_id":"nf2","ts":"2026-10-21T00:00:04Z"}\n' | nf_append 2>&1 >/dev/null)" RC=$? if [[ $RC -eq 1 && $((SECONDS - start)) -lt 5 && "$ERR" == *".lockdir is stale"*"remove it"* \ - && "$(wc -l <"$REPO/.work/buffer/findings.jsonl")" -eq "$before" ]]; then + && "$(wc -l <"$REPO/.git/findings-buffer/findings.jsonl")" -eq "$before" ]]; then pass "a dead holder's lock fails fast, names the lock and stores nothing" else fail "a dead holder's lock fails fast, names the lock and stores nothing" "exit $RC after $((SECONDS - start))s: $ERR" fi -rm -rf "$REPO/.work/buffer/.lockdir" +rm -rf "$REPO/.git/findings-buffer/.lockdir" # perl's flock (flock hidden from PATH, perl present): it locks .lock, an append # after a SIGKILLed holder succeeds, and parallel appends never share the lock @@ -219,7 +219,7 @@ if perl -MConfig -MFcntl=:flock -e 'exit !$Config{d_flock}' >/dev/null 2>&1; the pl_append() { (cd "$PREPO" && PATH="$PERLBIN" bash "$LOCAL/append.sh"); } printf '{"obs_id":"pl1","ts":"2026-10-21T00:00:07Z"}\n' | pl_append RC=$? - if [[ $RC -eq 0 && -f "$PREPO/.work/buffer/.lock" ]]; then + if [[ $RC -eq 0 && -f "$PREPO/.git/findings-buffer/.lock" ]]; then pass "append.sh without flock takes perl's flock on .lock" else fail "append.sh without flock takes perl's flock on .lock" "exit $RC" @@ -253,12 +253,12 @@ EOF ERR="$(printf '{"obs_id":"pl2","ts":"2026-10-21T00:00:09Z"}\n' | pl_append 2>&1 >/dev/null)" RC=$? if [[ $RC -eq 0 && $((SECONDS - start)) -lt 5 \ - && "$(grep -c '"obs_id":"pl2"' "$PREPO/.work/buffer/findings.jsonl")" -eq 1 ]]; then + && "$(grep -c '"obs_id":"pl2"' "$PREPO/.git/findings-buffer/findings.jsonl")" -eq 1 ]]; then pass "without flock, an append after a SIGKILLed holder exits 0 and stores its row" else fail "without flock, an append after a SIGKILLed holder exits 0 and stores its row" "exit $RC after $((SECONDS - start))s: $ERR" fi - rm -rf "$PREPO/.work/buffer/.lockdir" + rm -rf "$PREPO/.git/findings-buffer/.lockdir" # A sync stub that finds another appender inside the lock records an overlap; # each appender stays inside long enough for one to show. @@ -288,7 +288,7 @@ EOF done bad=0 for p in "${pids[@]}"; do wait "$p" || bad=$((bad + 1)); done - ids="$(jq -rR 'fromjson | .obs_id' "$PREPO/.work/buffer/findings.jsonl" 2>/dev/null)" + ids="$(jq -rR 'fromjson | .obs_id' "$PREPO/.git/findings-buffer/findings.jsonl" 2>/dev/null)" RC=$? off="" for i in $(seq 1 $N); do @@ -308,48 +308,41 @@ else printf 'ok %d - perl flock checks # SKIP no perl with native flock(2)\n' "$CASE_NUM" fi -# the buffer is git-ignored, and symlinks never redirect rows or truncate the lock +# the buffer sits where git tracks nothing, and symlinks never redirect rows or truncate the lock LREPO="$TEST_TMPDIR/links" +LBUF="$LREPO/.git/findings-buffer" git init -q -b main "$LREPO" >/dev/null 2>&1 la() { (cd "$LREPO" && bash "$LOCAL/append.sh" >/dev/null 2>&1); } printf '{"obs_id":"g","ts":"2026-10-21T00:00:00Z"}\n' | la -if [[ "$(cat "$LREPO/.work/buffer/.gitignore" 2>/dev/null)" == "*" \ - && -z "$(git -C "$LREPO" status --porcelain --untracked-files=all)" ]]; then - pass "first append writes .work/buffer/.gitignore with *, so git sees no rows" +if [[ -s "$LBUF/findings.jsonl" && -z "$(git -C "$LREPO" status --porcelain --untracked-files=all --ignored)" ]]; then + pass "first append stores under .git/findings-buffer, so git sees no rows" else - fail "first append writes .work/buffer/.gitignore with *, so git sees no rows" + fail "first append stores under .git/findings-buffer, so git sees no rows" fi -rm -f "$LREPO/.work/buffer/.gitignore" -printf '{"obs_id":"g2","ts":"2026-10-21T00:00:00Z"}\n' | la -if [[ "$(cat "$LREPO/.work/buffer/.gitignore" 2>/dev/null)" == "*" ]]; then - pass "an append restores a missing .gitignore in an existing buffer directory" -else - fail "an append restores a missing .gitignore in an existing buffer directory" -fi -GI_OUT="$TEST_TMPDIR/gitignore-target" -rm -f "$LREPO/.work/buffer/.gitignore" "$GI_OUT" -ln -s "$GI_OUT" "$LREPO/.work/buffer/.gitignore" -printf '{"obs_id":"g3","ts":"2026-10-21T00:00:00Z"}\n' | la -RC=$? -if [[ $RC -eq 2 && ! -e "$GI_OUT" ]]; then - pass "append.sh refuses a symlinked .gitignore and writes nothing through it" -else - fail "append.sh refuses a symlinked .gitignore and writes nothing through it" "exit $RC" -fi -rm -f "$LREPO/.work/buffer/.gitignore" if command -v flock >/dev/null 2>&1; then - printf 'keep\n' >"$LREPO/.work/buffer/.lock" + printf 'keep\n' >"$LBUF/.lock" printf '{"obs_id":"g2","ts":"2026-10-21T00:00:00Z"}\n' | la - if [[ "$(cat "$LREPO/.work/buffer/.lock")" == "keep" ]]; then + if [[ "$(cat "$LBUF/.lock")" == "keep" ]]; then pass "the flock path does not truncate .lock" else fail "the flock path does not truncate .lock" fi fi +LOCK_OUT="$TEST_TMPDIR/lock-target" +rm -f "$LBUF/.lock" "$LOCK_OUT" +ln -s "$LOCK_OUT" "$LBUF/.lock" +printf '{"obs_id":"g3","ts":"2026-10-21T00:00:00Z"}\n' | la +RC=$? +if [[ $RC -eq 2 && ! -e "$LOCK_OUT" ]]; then + pass "append.sh refuses a symlinked .lock and writes nothing through it" +else + fail "append.sh refuses a symlinked .lock and writes nothing through it" "exit $RC" +fi +rm -f "$LBUF/.lock" OUTSIDE="$TEST_TMPDIR/outside.jsonl" : >"$OUTSIDE" -mv "$LREPO/.work/buffer/findings.jsonl" "$LREPO/.work/buffer/real.jsonl" -ln -s "$OUTSIDE" "$LREPO/.work/buffer/findings.jsonl" +mv "$LBUF/findings.jsonl" "$LBUF/real.jsonl" +ln -s "$OUTSIDE" "$LBUF/findings.jsonl" printf '{"obs_id":"s1","ts":"2026-10-21T00:00:00Z"}\n' | la RC=$? if [[ $RC -eq 2 && ! -s "$OUTSIDE" ]]; then @@ -361,15 +354,83 @@ fi expect_exit 2 $? "read.sh refuses a symlinked findings.jsonl" OUTDIR="$TEST_TMPDIR/outside-dir" mkdir -p "$OUTDIR" -rm -rf "$LREPO/.work/buffer" -ln -s "$OUTDIR" "$LREPO/.work/buffer" +rm -rf "$LREPO/.git/findings-buffer" +ln -s "$OUTDIR" "$LBUF" printf '{"obs_id":"s2","ts":"2026-10-21T00:00:00Z"}\n' | la RC=$? if [[ $RC -eq 2 && -z "$(ls -A "$OUTDIR")" ]]; then - pass "append.sh refuses a symlinked .work/buffer and writes nothing there" + pass "append.sh refuses a symlinked .git/findings-buffer and writes nothing there" +else + fail "append.sh refuses a symlinked .git/findings-buffer and writes nothing there" "exit $RC" +fi +(cd "$LREPO" && bash "$LOCAL/read.sh" >/dev/null 2>&1) +expect_exit 2 $? "read.sh refuses a symlinked .git/findings-buffer" + +# every worktree of one clone shares one buffer +MAIN="$TEST_TMPDIR/shared-main" +LINKED="$TEST_TMPDIR/shared-linked" +git init -q -b main "$MAIN" >/dev/null 2>&1 +git -C "$MAIN" -c user.name=fixture -c user.email=fixture@example.invalid -c commit.gpgsign=false \ + commit -q --allow-empty -m fixture +git -C "$MAIN" worktree add -q -b linked "$LINKED" >/dev/null 2>&1 +printf '{"obs_id":"from-linked","ts":"2026-10-21T00:00:00Z"}\n' | (cd "$LINKED" && bash "$LOCAL/append.sh") +if grep -q '"obs_id":"from-linked"' <<<"$(cd "$MAIN" && bash "$LOCAL/read.sh")" \ + && grep -q '"obs_id":"from-linked"' "$MAIN/.git/findings-buffer/findings.jsonl"; then + pass "a row appended in a linked worktree is read from the main worktree" +else + fail "a row appended in a linked worktree is read from the main worktree" +fi +printf '{"obs_id":"from-main","ts":"2026-10-21T00:00:00Z"}\n' | (cd "$MAIN" && bash "$LOCAL/append.sh") +if grep -q '"obs_id":"from-main"' <<<"$(cd "$LINKED" && bash "$LOCAL/read.sh")"; then + pass "a row appended in the main worktree is read from a linked worktree" +else + fail "a row appended in the main worktree is read from a linked worktree" +fi +pids=() +for i in $(seq 1 6); do + for w in "$MAIN" "$LINKED"; do + printf '{"obs_id":"w%d-%s","ts":"2026-10-21T00:00:00Z"}\n' "$i" "${w##*-}" | (cd "$w" && bash "$LOCAL/append.sh") & + pids+=($!) + done +done +bad=0 +for p in "${pids[@]}"; do wait "$p" || bad=$((bad + 1)); done +ids="$(cd "$MAIN" && bash "$LOCAL/read.sh" | jq -r .obs_id)" +off="" +for i in $(seq 1 6); do + for s in main linked; do + cnt="$(grep -cx "w$i-$s" <<<"$ids")" + [[ "$cnt" == 1 ]] || off="$off w$i-$s(x$cnt)" + done +done +if [[ $bad -eq 0 && -z "$off" ]]; then + pass "concurrent appends from two worktrees all exit 0 and store every row once" +else + fail "concurrent appends from two worktrees all exit 0 and store every row once" "$bad failed, off:$off" +fi + +# rows in the per-worktree .work/buffer file written before the buffer moved are still read +LEGACY="$LINKED/.work/buffer" +mkdir -p "$LEGACY" +printf '{"obs_id":"legacy-new","ts":"2026-10-22T00:00:00Z"}\n{"obs_id":"legacy-old","ts":"2020-01-01T00:00:00Z"}\n' \ + >"$LEGACY/findings.jsonl" +OUT="$(cd "$LINKED" && bash "$LOCAL/read.sh")" +if grep -q '"obs_id":"legacy-new"' <<<"$OUT" && grep -q '"obs_id":"legacy-old"' <<<"$OUT" \ + && grep -q '"obs_id":"from-main"' <<<"$OUT"; then + pass "read.sh returns the legacy .work/buffer rows beside the shared rows" +else + fail "read.sh returns the legacy .work/buffer rows beside the shared rows" "out: $OUT" +fi +OUT="$(cd "$LINKED" && bash "$LOCAL/read.sh" --since 2026-10-21T12:00:00Z)" +if grep -q '"obs_id":"legacy-new"' <<<"$OUT" && ! grep -q '"obs_id":"legacy-old"' <<<"$OUT"; then + pass "read.sh --since filters the legacy rows too" else - fail "append.sh refuses a symlinked .work/buffer and writes nothing there" "exit $RC" + fail "read.sh --since filters the legacy rows too" "out: $OUT" fi +mv "$LEGACY/findings.jsonl" "$LEGACY/real.jsonl" +ln -s "$LEGACY/real.jsonl" "$LEGACY/findings.jsonl" +(cd "$LINKED" && bash "$LOCAL/read.sh" >/dev/null 2>&1) +expect_exit 2 $? "read.sh refuses a symlinked legacy findings.jsonl" # outside a repository NOREPO="$TEST_TMPDIR/norepo" From 910bc03b78b79800d04f25121ef665bc92fdf503 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Tue, 3 Nov 2026 14:11:34 -0500 Subject: [PATCH 2/3] fix(corrections): skip a symlinked legacy buffer instead of failing the read A symlinked .work made read.sh exit 2 before it read the shared buffer, while append.sh, which no longer touches .work, kept succeeding: rows went in and could not be read back. The legacy path now skips only the legacy file with a note on stderr; the shared buffer keeps its hard symlink refusal. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MWmASLCzgJayUQq9Saq4AK --- .../tools/findings-buffer/CONTRACT.md | 2 +- .../adapters/local-file/read.sh | 12 ++++++++++-- .../tools/findings-buffer/conformance.test.sh | 18 ++++++++++++++++-- 3 files changed, 27 insertions(+), 5 deletions(-) diff --git a/plugins/corrections/tools/findings-buffer/CONTRACT.md b/plugins/corrections/tools/findings-buffer/CONTRACT.md index 42a5115a68e..ae6a22c6a22 100644 --- a/plugins/corrections/tools/findings-buffer/CONTRACT.md +++ b/plugins/corrections/tools/findings-buffer/CONTRACT.md @@ -94,7 +94,7 @@ only an adapter that keeps rows on the machine until routing to a hosted sink ex | Adapter | Storage | |---|---| -| `local-file` | `findings-buffer/findings.jsonl` under the git common directory (`git rev-parse --path-format=absolute --git-common-dir`), created on first append. Every worktree of one clone shares that directory, and git tracks nothing in it, so rows never reach a commit. `read.sh` also returns the rows of `.work/buffer/findings.jsonl` under the current worktree's root, where appends stored them before; `buffer.sh read` drops a repeat `obs_id`. Both verbs refuse (exit `2`, nothing stored) when `findings-buffer`, the lock or `findings.jsonl` is a symlink, and `read.sh` when `.work`, `.work/buffer` or the old `findings.jsonl` is; `append.sh` also refuses when the buffer resolves outside the git common directory. Appends take an exclusive lock, held across worktrees, so concurrent calls never interleave or lose a row: `flock` on `findings-buffer/.lock`; where `flock` is absent (macOS), perl's `flock` on the same file when perl calls flock(2) itself (`$Config{d_flock}`); where neither is available, a `findings-buffer/.lockdir` directory holding the holder's pid. A flock(2) lock ends when every process holding it has exited, so an appender killed with SIGKILL holds up no later append. The holder removes the `.lockdir` directory on exit, and on HUP, INT or TERM, while its pid file still holds the holder's pid. That directory has no automatic takeover: on a host with neither, an appender killed with SIGKILL (or a power loss) leaves it behind, and every later append exits `1` naming it (at once when its pid is no longer running) until an operator removes it. Needs `jq`. Exits `3` outside a git repository or without `jq`. | +| `local-file` | `findings-buffer/findings.jsonl` under the git common directory (`git rev-parse --path-format=absolute --git-common-dir`), created on first append. Every worktree of one clone shares that directory, and git tracks nothing in it, so rows never reach a commit. `read.sh` also returns the rows of `.work/buffer/findings.jsonl` under the current worktree's root, where appends stored them before; `buffer.sh read` drops a repeat `obs_id`. Both verbs refuse (exit `2`, nothing stored) when `findings-buffer`, the lock or `findings.jsonl` is a symlink; `read.sh` skips the old file, with a note on stderr, when `.work`, `.work/buffer` or the old `findings.jsonl` is; `append.sh` also refuses when the buffer resolves outside the git common directory. Appends take an exclusive lock, held across worktrees, so concurrent calls never interleave or lose a row: `flock` on `findings-buffer/.lock`; where `flock` is absent (macOS), perl's `flock` on the same file when perl calls flock(2) itself (`$Config{d_flock}`); where neither is available, a `findings-buffer/.lockdir` directory holding the holder's pid. A flock(2) lock ends when every process holding it has exited, so an appender killed with SIGKILL holds up no later append. The holder removes the `.lockdir` directory on exit, and on HUP, INT or TERM, while its pid file still holds the holder's pid. That directory has no automatic takeover: on a host with neither, an appender killed with SIGKILL (or a power loss) leaves it behind, and every later append exits `1` naming it (at once when its pid is no longer running) until an operator removes it. Needs `jq`. Exits `3` outside a git repository or without `jq`. | ## Core diff --git a/plugins/corrections/tools/findings-buffer/adapters/local-file/read.sh b/plugins/corrections/tools/findings-buffer/adapters/local-file/read.sh index c14d4c6c91a..e84931e5e59 100755 --- a/plugins/corrections/tools/findings-buffer/adapters/local-file/read.sh +++ b/plugins/corrections/tools/findings-buffer/adapters/local-file/read.sh @@ -46,8 +46,16 @@ refuse_links() { refuse_links "$COMMON/findings-buffer" "$COMMON/findings-buffer/findings.jsonl" [[ -f "$COMMON/findings-buffer/findings.jsonl" ]] && FILES+=("$COMMON/findings-buffer/findings.jsonl") if TOP="$(git rev-parse --show-toplevel 2>/dev/null)" && [[ -n "$TOP" ]]; then - refuse_links "$TOP/.work" "$TOP/.work/buffer" "$TOP/.work/buffer/findings.jsonl" - [[ -f "$TOP/.work/buffer/findings.jsonl" ]] && FILES+=("$TOP/.work/buffer/findings.jsonl") + # A symlink on the legacy path skips only the legacy file, so the shared rows stay readable. + LEGACY_OK=1 + for p in "$TOP/.work" "$TOP/.work/buffer" "$TOP/.work/buffer/findings.jsonl"; do + if [[ -L "$p" ]]; then + echo "read.sh: $p is a symlink; skipping the legacy buffer" >&2 + LEGACY_OK=0 + break + fi + done + [[ $LEGACY_OK -eq 1 && -f "$TOP/.work/buffer/findings.jsonl" ]] && FILES+=("$TOP/.work/buffer/findings.jsonl") fi [[ ${#FILES[@]} -gt 0 ]] || exit 0 diff --git a/plugins/corrections/tools/findings-buffer/conformance.test.sh b/plugins/corrections/tools/findings-buffer/conformance.test.sh index e8b33fa9c2a..95f083b7a9d 100755 --- a/plugins/corrections/tools/findings-buffer/conformance.test.sh +++ b/plugins/corrections/tools/findings-buffer/conformance.test.sh @@ -429,8 +429,22 @@ else fi mv "$LEGACY/findings.jsonl" "$LEGACY/real.jsonl" ln -s "$LEGACY/real.jsonl" "$LEGACY/findings.jsonl" -(cd "$LINKED" && bash "$LOCAL/read.sh" >/dev/null 2>&1) -expect_exit 2 $? "read.sh refuses a symlinked legacy findings.jsonl" +OUT="$(cd "$LINKED" && bash "$LOCAL/read.sh" 2>/dev/null)" +rc=$? +if [[ $rc -eq 0 ]] && grep -q '"obs_id":"from-main"' <<<"$OUT" && ! grep -q '"obs_id":"legacy-new"' <<<"$OUT"; then + pass "read.sh skips a symlinked legacy findings.jsonl and still returns the shared rows" +else + fail "read.sh skips a symlinked legacy findings.jsonl and still returns the shared rows" "rc: $rc out: $OUT" +fi +rm -rf "${LINKED:?}/.work" +ln -s "$TEST_TMPDIR" "$LINKED/.work" +OUT="$(cd "$LINKED" && bash "$LOCAL/read.sh" 2>/dev/null)" +rc=$? +if [[ $rc -eq 0 ]] && grep -q '"obs_id":"from-main"' <<<"$OUT"; then + pass "read.sh with a symlinked .work still returns the shared rows" +else + fail "read.sh with a symlinked .work still returns the shared rows" "rc: $rc out: $OUT" +fi # outside a repository NOREPO="$TEST_TMPDIR/norepo" From d3cc7fba968efdd3befc99b55c41ed38da69bc49 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Tue, 3 Nov 2026 14:19:35 -0500 Subject: [PATCH 3/3] test(corrections): prove read.sh skips rows behind a symlinked .work The symlinked-.work case pointed at an empty directory, so it passed with the skip removed. Put a row behind the link and assert it is left out; the case now fails when "$TOP/.work" leaves the skip list. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MWmASLCzgJayUQq9Saq4AK --- .../corrections/tools/findings-buffer/conformance.test.sh | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/plugins/corrections/tools/findings-buffer/conformance.test.sh b/plugins/corrections/tools/findings-buffer/conformance.test.sh index 95f083b7a9d..cff304ec72c 100755 --- a/plugins/corrections/tools/findings-buffer/conformance.test.sh +++ b/plugins/corrections/tools/findings-buffer/conformance.test.sh @@ -437,10 +437,12 @@ else fail "read.sh skips a symlinked legacy findings.jsonl and still returns the shared rows" "rc: $rc out: $OUT" fi rm -rf "${LINKED:?}/.work" -ln -s "$TEST_TMPDIR" "$LINKED/.work" +mkdir -p "$TEST_TMPDIR/linked-work/buffer" +printf '{"obs_id":"via-linked-work","ts":"2026-10-22T00:00:00Z"}\n' >"$TEST_TMPDIR/linked-work/buffer/findings.jsonl" +ln -s "$TEST_TMPDIR/linked-work" "$LINKED/.work" OUT="$(cd "$LINKED" && bash "$LOCAL/read.sh" 2>/dev/null)" rc=$? -if [[ $rc -eq 0 ]] && grep -q '"obs_id":"from-main"' <<<"$OUT"; then +if [[ $rc -eq 0 ]] && grep -q '"obs_id":"from-main"' <<<"$OUT" && ! grep -q '"obs_id":"via-linked-work"' <<<"$OUT"; then pass "read.sh with a symlinked .work still returns the shared rows" else fail "read.sh with a symlinked .work still returns the shared rows" "rc: $rc out: $OUT"