fix(autoplan): Phase 4 task aggregator emitted zero tasks on every run (#2018)

The branch+commit jq filter piped to the split commit array and then
referenced .commit — jq rebinds context across a pipe, so .commit indexed
the ARRAY with a string, every input line errored into 2>/dev/null, and
|| true swallowed the exit. The aggregate table has been empty for every
user since the feature shipped. Bind .commit to a variable before the pipe.

Functional pin extracts the ACTUAL emitted jq program from the resolver and
runs it against fixture JSONL (verified RED against the broken filter), plus
a source-shape guard against reintroducing a context-rebinding reference.

Fix mechanism from time-attack/gstack (GStack 2).

Co-authored-by: Sina Matian <sina@time-attack.dev>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Garry Tan 2026-08-14 11:49:24 -07:00
parent 7531ae2b16
commit 6e8a1ae87f
No known key found for this signature in database
GPG Key ID: C1F69E85C74EFE1D
2 changed files with 111 additions and 1 deletions

View File

@ -118,8 +118,12 @@ if command -v jq >/dev/null 2>&1; then
# Filter to current branch + recent commits, then keep records for the
# latest run_id only. (Single phase may have multiple files if the user
# re-ran the review; aggregator takes the newest.)
# .commit must be bound BEFORE piping to the split commit array: a
# pipe rebinds jq's context, so a bare .commit after it indexes the
# ARRAY with a string, every line errors into 2>/dev/null, and the
# aggregate is empty forever the #2018 zero-tasks bug.
jq -c --arg branch "$BRANCH" --arg commits "$COMMITS_RECENT" \\
'select(.branch == $branch and ($commits | split("|") | index(.commit) != null))' \\
'.commit as $c | select(.branch == $branch and ($commits | split("|") | index($c) != null))' \\
"$f" 2>/dev/null >> "$ALL_JSONL" || true
done < <(find "$TASKS_DIR" -maxdepth 1 -name "tasks-$phase-*.jsonl" 2>/dev/null | sort)
# Reduce to latest run_id per phase

View File

@ -0,0 +1,106 @@
/**
* Regression pin for #2018: /autoplan Phase 4's task aggregator emitted zero
* tasks on every run, forever, for everyone.
*
* Root cause: the branch+commit filter in scripts/resolvers/tasks-section.ts
* piped to the split commit array and THEN referenced `.commit`
*
* select(.branch == $branch and ($commits | split("|") | index(.commit) != null))
*
* In jq, the pipe rebinds the context, so `.commit` was evaluated against the
* split ARRAY ("Cannot index array with string \"commit\""), every input line
* errored, stderr went to /dev/null, `|| true` swallowed the exit code, and
* the aggregate was empty. A dead feature indistinguishable from "no tasks".
*
* These tests run the ACTUAL jq program extracted from the resolver source
* against fixture JSONL, so they were RED against the broken filter and stay
* red if anyone reintroduces a context-rebinding shape.
*/
import { describe, it, expect } from "bun:test";
import { execFileSync } from "child_process";
import { readFileSync } from "fs";
import { join } from "path";
const SOURCE_PATH = join(import.meta.dir, "..", "scripts", "resolvers", "tasks-section.ts");
/**
* Extract the emitted jq filter program from the resolver source. The
* resolver builds bash inside a TS template literal, so `\\` in source is a
* bash line-continuation `\` — strip it when unescaping. We match the
* single-quoted jq program on the line that filters by $branch + $commits.
*/
function extractBranchCommitFilter(): string {
const src = readFileSync(SOURCE_PATH, "utf-8");
const m = src.match(/'([^']*select\(\.branch == \$branch[^']*)'/);
if (!m) throw new Error("branch+commit jq filter not found in tasks-section.ts");
return m[1].replace(/\\\\/g, "\\");
}
function runJq(program: string, inputLines: string[], branch: string, commits: string): string[] {
const out = execFileSync(
"jq",
["-c", "--arg", "branch", branch, "--arg", "commits", commits, program],
{ input: inputLines.join("\n"), encoding: "utf-8" },
);
return out.split("\n").filter(Boolean);
}
const RECORD = (branch: string, commit: string) =>
JSON.stringify({
phase: "ceo-review",
run_id: "20260814T000000Z-1",
branch,
commit,
id: "T1",
priority: "P1",
component: "demo",
files: ["a.ts"],
effort_human: "~1h",
effort_cc: "~5min",
title: "demo task",
source_finding: "demo finding",
});
describe("tasks-section jq filter (#2018)", () => {
it("matches a record whose branch and commit are in the window", () => {
const program = extractBranchCommitFilter();
const matched = runJq(
program,
[RECORD("feature/x", "abc123")],
"feature/x",
"abc123|def456",
);
// The broken filter returned [] here (every line errored) — the exact
// #2018 symptom: reviews produced tasks, the aggregate table showed none.
expect(matched).toHaveLength(1);
expect(JSON.parse(matched[0]).id).toBe("T1");
});
it("filters out other branches and out-of-window commits", () => {
const program = extractBranchCommitFilter();
const matched = runJq(
program,
[
RECORD("feature/x", "abc123"),
RECORD("other-branch", "abc123"),
RECORD("feature/x", "zzz999"),
],
"feature/x",
"abc123|def456",
);
expect(matched).toHaveLength(1);
});
it("errors on no input line at all rather than fabricating output", () => {
const program = extractBranchCommitFilter();
expect(runJq(program, [], "feature/x", "abc123")).toHaveLength(0);
});
it("source does not reference .commit after a context-rebinding pipe", () => {
const src = readFileSync(SOURCE_PATH, "utf-8");
// The bug shape: split("|") piped, then a bare `.commit` in the new array
// context. Binding first (`.commit as $c`) is the required form.
expect(src).not.toMatch(/split\("\|"\)\s*\|\s*index\(\.commit\)/);
});
});