Files
stack/agents/dewey/work/queue-89/build.md
T
jason.woltjeandClaude Opus 5.5 4a2e18c90d dewey: row 89 (#1583) PM task reads packet, round 1, manifest b8c5bb3a
Candidate on base a3f6e9e6: tasks takes filters and paging, list_tasks and get_task,
a 40000-character reply cap with compact JSON, and the built-in tools line per harness.
Gate and 34 mutants in the packet.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 15:45:08 -05:00

12 KiB

Row 89 (#1583): PM task reads that fit a session, round 1

Owner: Dewey. Reviewer: Darkwing, then Sage's gate. Brief: docs/plans/2026-10-12_pm-task-flow.md, section "PM task reads that fit a session" (blob e8a1150e, commit 4d095b77).

Base a3f6e9e6 (queue rev 744). build.patch is git diff --cached --binary a3f6e9e6 -- the twelve paths in candidate-manifest.sha256. Apply it with git apply --index to a detached worktree of the base; the manifest then checks 12/12 OK. No commit from the base to refactor HEAD 70b7b8d5 touches the twelve paths.

File What changed
packages/bus/src/broker.mjs tasks takes optional arguments; #tasks filters and pages the short form
packages/bus/tests/task-page.test.mjs new: six tests on a 500-task board
packages/bus/README.md the tasks row and a paragraph on its arguments
packages/harness/src/tools.mjs list_tasks arguments, get_task, the done: false default, REPLY_CAP, replyText
packages/harness/src/mcp-server.mjs replies through replyText
packages/harness/src/pi-extension.mjs replies through replyText
packages/harness/src/bundle.mjs the built-in tools line names the harness's own tools
packages/harness/tests/tools.test.mjs the schemas, the default, replyText's boundary
packages/harness/tests/mcp-server.test.mjs compact JSON, the default sent, the cap from the MCP server
packages/harness/tests/pi-session.test.mjs compact JSON, the cap, list_tasks and get_task from a real pi session
packages/harness/tests/bundle.test.mjs the tools line for each harness, and "none"
packages/harness/README.md the tools line, compact replies, the cap, the two task tools, a limit

The threshold, and the cap

The bus unit runs MOSAIC_CLAUDE_BIN=~/.local/bin/claude (decision 91), which is version 2.1.296 today. I found the threshold in that binary and then confirmed it with a probe.

  • The binary. The MCP tool's maxResultSizeChars is 1e5. The function that picks the persist threshold takes the minimum of that and pH=50000. A GrowthBook flag, tengu_velvet_ibis, can override it per tool. cachedGrowthBookFeatures is {} in both Claude config files on this host, so no override applies. A result is saved to a file when the total .length of its text blocks is over the threshold. The same variable list holds tjn=200000, a budget per message across all tool results. MAX_MCP_OUTPUT_TOKENS (25000 tokens by default) comes later than 50000 characters for this kind of text.
  • The probe (probe/). probe-server.mjs is a stdio MCP server whose one tool returns a text block of exactly n characters. A headless claude -p run of 2.1.296 (haiku, cost $0.0145) called it with 49990, 50000 and 50010 (probe/results.txt):
    • 49990 and 50000 came back inline, at full length.
    • 50010 came back as "Error: result (50,010 characters across 1 line) exceeds maximum allowed tokens. Output has been saved to" a file under ~/.claude/projects/, outside the workspace. That is the PM's fault.

So the threshold is "over 50000 characters", counted as JavaScript counts (UTF-16 units). REPLY_CAP is 40000, 20% under it. replyText measures with .length too, so a reply of astral characters is counted the way the CLI counts it (the tools test has a surrogate-pair case at 40002).

Over the cap, the tool throws "reply too large: N characters, over the 40000-character cap. Narrow the query: a filter, a smaller limit, or get_task for one task." Both servers report a throw as a tool error (isError on MCP, a thrown error on pi). A reply is never cut short. Replies are compact JSON in both servers; that is the cap's input.

Sizes on the test board

500 tasks, titles about 55 characters, descriptions over 2000:

Read Compact Indented
list_tasks default (open, 50) 11,210 16,618
limit: 200 44,698, refused by the cap 66,303
no arguments, the whole board 1,518,073
get_task, one full row 3,028 (task 2; 3,055 at most, 365 for a tombstone)

The default page is about a quarter of the cap. limit: 200 with these titles is over it, so the session gets the cap error and narrows. That's the cap doing its job, and the reason the default is 50.

Decisions

  1. The no-argument read is unchanged. tasks with {} returns #taskRows as before, so the Console and mosaic tasks read what they did. A test compares it with taskView for the pm, a human and a reader.
  2. The tool always filters. ARG_DEFAULTS = { list_tasks: { done: false } } is merged in callTool, under the session's own arguments. A session can't reach the whole board through list_tasks, even with no arguments; done: true overrides the default.
  3. task_ref stands alone. With any other argument it refuses invalid-request. A ref not on the board refuses task-not-found, a code the broker already has. The row is today's full row, tombstones included.
  4. Tombstones are left out of filtered reads. A tombstone has no title or state to show, and isn't on the board. get_task still returns one by ref.
  5. Assignees are roles. The short form lists the roles whose roles[*].tracker.botId is among the task's assignees, sorted. A person or another bot isn't a role and doesn't appear. A role whose tracker is null holds no task, so assignee for it matches nothing. An assignee that isn't a role of the business refuses (toString included; the check is Object.hasOwn).
  6. changed_at is the row's at, the time the broker last recorded a change, as the Console's board shows it.
  7. Paging is in task_ref order as text, the order #taskRows already gives (ORDER BY task_ref), and after compares with > on the same strings. after needn't be on the board or match the filters; the page starts past it. total counts every match, not the page. next is the last ref of the page, null when the page ends on the last match.
  8. The page sizes live in two files. TASK_PAGE and TASK_PAGE_MAX are exported from broker.mjs and repeated in tools.mjs, so the session's tool server doesn't load the broker. A tools test holds the schema maximum equal to the broker's.
  9. The tools line is built in prompt(). For pi it prints the role's tools as before; for Claude Code, claudeBuiltins({ tools }), the names the gate gives Claude Code. Its call site is unchanged. With no tools, it says "none" for both.

Limits

  • The cap is per reply. The CLI's 200000-character budget is per message: six or more replies near the cap in one turn could still be saved to a file. Five at the cap total exactly 200000, which isn't over.
  • A full row over 40000 characters can't be read through get_task. The session gets the cap error. The test board's longest full row is 3,055; I haven't measured the live board's.
  • The threshold belongs to 2.1.296. ~/.local/bin/claude updates itself, so a later version can move it. The harness README says so. The probe here rechecks a new version in one run.
  • limit: 200 can exceed the cap on a real board with long titles, as it does on the test board. The error tells the session to narrow.

Gate (my run on the candidate)

Sequential, in a worktree of a3f6e9e6 holding the candidate, TMPDIR in my scratch, test-task.sh with DOCKER_HOST=unix:///nonexistent (gate.sh, gate-summary.txt). The brief's four node suites, plus tasks and control-board, then every scripts/test-*.sh. 2026-10-12T20:13:35Z to 20:18:12Z, all rc=0:

Suite Result
bus 108 pass (the six in task-page.test.mjs among them)
harness 65 pass
webui 84 pass
cli 115 pass
tasks 74 pass
control-board 124 pass
test-auth 15 passed
test-conductor 17 passed
test-config 24 passed
test-discord 66 passed
test-extension-package 18 passed
test-foundation 44 passed
test-queue 27 passed
test-release 14 passed
test-task 26 passed; four Docker groups skip (adapter seam, workspace/capability, live task, live recall pair)

After the mutants I added one assertion to packages/harness/tests/tools.test.mjs (below), so the manifest's hash for that file is newer than this run. No other suite loads that file. The harness suite on the final candidate, 20:43:56Z to 20:44:07Z: 65 pass, rc=0 (the last line of gate-summary.txt).

Mutants

mutants.py swaps one exact string in a second worktree of the base that holds the candidate. It runs the bus or harness node suite, restores the file and reports killed or SURVIVED; an anchor matching anything but once is reported and skipped (none was). 34 mutants, 34 killed in the end. The copy checked 12/12 against the manifest afterwards.

  • Run 1 (mutants-run1.txt, from 20:18:19Z). The first 16 were killed. The 17th, after ignored, makes pages() in task-page.test.mjs follow next forever. It hung until the harness's 900 s timeout, and the test child it left behind ran on until I stopped it by PID at 20:35Z. My harness's fault: it killed only the runner, not the runner's children. mutants.py now starts each run in its own process group, kills the group after 300 s, and reports a hang as a kill.
  • Run 2 (mutants-run2.txt, 20:35:08Z to 20:43:08Z), the 18 from after ignored on. 17 killed, after ignored among them as a hang. One survived: cap counts bytes (Buffer.byteLength for .length). The surrogate-pair case at 40002 units is over the cap either way, so it can't tell units from bytes. I added a case that fits by units and not by bytes: 19999 emoji, 40000 units and 80002 bytes, fits.
  • Run 3 (mutants-run3.txt, 20:43:36Z to 20:43:47Z): cap counts bytes killed.
Mutant Site What it undoes
no args filtered too broker the no-argument read returns the whole projection
tombstones kept broker filtered reads leave tombstones out
next on an exact last page broker next is null when the page ends on the last match
default limit ignored broker the page is 50 by default
limit max unchecked broker limit over 200 refuses
assignee by raw id broker assignees are role names
assignees keep persons broker a person's assignment shows as no role
task_ref with other args broker task_ref stands alone
missing task_ref is null broker a ref not on the board refuses task-not-found
done check dropped broker done must be a boolean
done filter dropped broker done filters
assignee any string broker assignee must be a role of the business
label check dropped broker label must be a positive integer
label filter dropped broker label filters
requirement pattern dropped broker requirement must match the id pattern
requirement filter dropped broker requirement filters
after ignored broker after starts the page (killed by hanging)
after inclusive broker the page starts past after, not at it
total counts the page broker total counts every match
description kept broker the short form has no description
tasks keys closed broker tasks takes its arguments at all
ARG_DEFAULTS removed tools list_tasks sends done: false
default overrides the caller tools the session's done wins over the default
cap at >= tools a reply of exactly 40000 fits
cap counts bytes tools the cap counts UTF-16 units, as the CLI does
indented JSON tools replies are compact
cap 50000 tools the cap is 40000
mcp server uncapped mcp-server the MCP server applies the cap
pi uncapped pi-extension the pi extension applies the cap
tool limit max 500 tools the schema's limit maximum equals the broker's
get_task not required tools get_task requires task_ref
get_task without tracker tools get_task is for tracker holders only
bundle line pi names bundle Claude Code's line names its own tools
bundle none dropped bundle no tools says "none"