# 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" |