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

223 lines
12 KiB
Markdown

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