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]>
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
maxResultSizeCharsis1e5. The function that picks the persist threshold takes the minimum of that andpH=50000. A GrowthBook flag,tengu_velvet_ibis, can override it per tool.cachedGrowthBookFeaturesis{}in both Claude config files on this host, so no override applies. A result is saved to a file when the total.lengthof its text blocks is over the threshold. The same variable list holdstjn=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.mjsis a stdio MCP server whose one tool returns a text block of exactlyncharacters. A headlessclaude -prun 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
- The no-argument read is unchanged.
taskswith{}returns#taskRowsas before, so the Console andmosaic tasksread what they did. A test compares it withtaskViewfor the pm, a human and a reader. - The tool always filters.
ARG_DEFAULTS = { list_tasks: { done: false } }is merged incallTool, under the session's own arguments. A session can't reach the whole board throughlist_tasks, even with no arguments;done: trueoverrides the default. task_refstands alone. With any other argument it refusesinvalid-request. A ref not on the board refusestask-not-found, a code the broker already has. The row is today's full row, tombstones included.- Tombstones are left out of filtered reads. A tombstone has no
title or state to show, and isn't on the board.
get_taskstill returns one by ref. - Assignees are roles. The short form lists the roles whose
roles[*].tracker.botIdis among the task's assignees, sorted. A person or another bot isn't a role and doesn't appear. A role whosetrackeris null holds no task, soassigneefor it matches nothing. Anassigneethat isn't a role of the business refuses (toStringincluded; the check isObject.hasOwn). changed_atis the row'sat, the time the broker last recorded a change, as the Console's board shows it.- Paging is in
task_reforder as text, the order#taskRowsalready gives (ORDER BY task_ref), andaftercompares with>on the same strings.afterneedn't be on the board or match the filters; the page starts past it.totalcounts every match, not the page.nextis the last ref of the page, null when the page ends on the last match. - The page sizes live in two files.
TASK_PAGEandTASK_PAGE_MAXare exported frombroker.mjsand repeated intools.mjs, so the session's tool server doesn't load the broker. A tools test holds the schema maximum equal to the broker's. - 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/claudeupdates itself, so a later version can move it. The harness README says so. The probe here rechecks a new version in one run. limit: 200can 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, makespages()intask-page.test.mjsfollownextforever. 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.pynow 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 fromafter ignoredon. 17 killed,after ignoredamong them as a hang. One survived:cap counts bytes(Buffer.byteLengthfor.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 byteskilled.
| 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" |