From 4a2e18c90ddcc275ed72065f6005b6e3464abcbb Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Mon, 12 Oct 2026 15:45:08 -0500 Subject: [PATCH] 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 --- agents/dewey/work/queue-89/build.md | 222 +++++ agents/dewey/work/queue-89/build.patch | 774 ++++++++++++++++++ .../work/queue-89/candidate-manifest.sha256 | 12 + agents/dewey/work/queue-89/gate-summary.txt | 18 + agents/dewey/work/queue-89/gate.sh | 18 + agents/dewey/work/queue-89/mutants-run1.txt | 33 + agents/dewey/work/queue-89/mutants-run2.txt | 20 + agents/dewey/work/queue-89/mutants-run3.txt | 3 + agents/dewey/work/queue-89/mutants.py | 69 ++ .../work/queue-89/probe/probe-server.mjs | 16 + agents/dewey/work/queue-89/probe/results.txt | 7 + 11 files changed, 1192 insertions(+) create mode 100644 agents/dewey/work/queue-89/build.md create mode 100644 agents/dewey/work/queue-89/build.patch create mode 100644 agents/dewey/work/queue-89/candidate-manifest.sha256 create mode 100644 agents/dewey/work/queue-89/gate-summary.txt create mode 100755 agents/dewey/work/queue-89/gate.sh create mode 100644 agents/dewey/work/queue-89/mutants-run1.txt create mode 100644 agents/dewey/work/queue-89/mutants-run2.txt create mode 100644 agents/dewey/work/queue-89/mutants-run3.txt create mode 100644 agents/dewey/work/queue-89/mutants.py create mode 100644 agents/dewey/work/queue-89/probe/probe-server.mjs create mode 100644 agents/dewey/work/queue-89/probe/results.txt diff --git a/agents/dewey/work/queue-89/build.md b/agents/dewey/work/queue-89/build.md new file mode 100644 index 000000000..0d223838b --- /dev/null +++ b/agents/dewey/work/queue-89/build.md @@ -0,0 +1,222 @@ +# 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" | diff --git a/agents/dewey/work/queue-89/build.patch b/agents/dewey/work/queue-89/build.patch new file mode 100644 index 000000000..4c54173da --- /dev/null +++ b/agents/dewey/work/queue-89/build.patch @@ -0,0 +1,774 @@ +diff --git a/packages/bus/README.md b/packages/bus/README.md +index 69824a30..c5ca68ad 100644 +--- a/packages/bus/README.md ++++ b/packages/bus/README.md +@@ -125,7 +125,8 @@ an operator inspect content and delivery evidence after an uncertain result. + | `message.read` | `{id}`; recipient holder's acknowledgement; no new holder can acknowledge old holder's delivery. | + | `event.emit` | `{kind:'action.allowed',body:{action:'routine',target?},subject?}`; routine observations only. | + | `launch.revoke`, `launch.restore` | No args; outside-agent human CLI only. | +-| `inbox`, `tasks` | No args; plain JSON projections scoped to capability's business. | ++| `inbox` | No args; plain JSON projection scoped to capability's business. | ++| `tasks` | Optional args, scoped to capability's business (row 89, #1583). None: every row of the tasks projection (below). `{task_ref}` alone: that one row, or `task-not-found`. Any of `{done,assignee,label,requirement,limit,after}`: `{tasks,total,next}`, the short form (below). An unknown arg or bad value refuses `invalid-request`. | + | `agents` | No args; each held role's latest claim row in the capability's business. Human and reader sessions also get `state`, `working` and `inputNeeded` (Agent state, below); a role session gets the claim rows only. | + | `trail` | `{subject}`: task ref, decision UUID or message UUID. Linked records in broker write order. | + | `decisions` | `{list}`: `roles` or `recent`; human and reader sessions only. Open decisions routed to a role, or the last 50 closed. | +@@ -295,7 +296,20 @@ must not pretend a missing/inaccessible task is open or completed. Each row adds + computed at each read and never stored. `priority` is the current row's `fields.priority`, null + for a tombstone or a read with none (Vikunja's 0 stays 0). `requirement` is the requirement of the + first `task.created` for the same Vikunja task id in the business, so a task moved to another +-project keeps it; null for a task the stack didn't create. Trail joins ++project keeps it; null for a task the stack didn't create. ++ ++A `tasks` read with filters (row 89, #1583) is what a session's `list_tasks` makes; the full ++projection is too large for a tool reply on a real board. It leaves out tombstones and pages in ++`task_ref` order (as text). Each task is `{task_ref,title,done,bucket,assignees,labels,priority, ++requirement,changed_at}`: no description; `assignees` are the roles whose tracker bot is ++assigned, sorted, so a person's assignment shows as none; `changed_at` is the row's `at`, as the ++Console shows it. Filters: `done` (boolean), `assignee` (a role of the business), `label` (a ++label id), `requirement` (`REQ-[A-Z]+-[1-9][0-9]*`). `limit` is 1 to `TASK_PAGE_MAX` (200), ++`TASK_PAGE` (50) by default. `after` is a task ref, which needn't be on the board; the page holds ++the matches past it. `total` counts every match, `next` is the `after` for the following page, ++null on the last. ++ ++Trail joins + human request, messages/deliveries, decisions/resolutions, task snapshots and + session/claim evidence. Broker timestamps advance monotonically across all + its writes, including after reopen; per-table `seq` stays the primary table +diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs +index 4389b353..4a3e52a4 100644 +--- a/packages/bus/src/broker.mjs ++++ b/packages/bus/src/broker.mjs +@@ -42,6 +42,10 @@ const CLOSED = "('resolved','withdrawn','expired')"; + export const RECENT = 50; + // How many replies `replies` returns, oldest first. + export const REPLIES = 50; ++// Row 89 (#1583): the arguments `tasks` takes, and its page size by default and at most. ++const TASK_ARGS = ['done', 'assignee', 'label', 'requirement', 'limit', 'after', 'task_ref']; ++export const TASK_PAGE = 50; ++export const TASK_PAGE_MAX = 200; + export const TASK_VERBS = Object.freeze(ACTIONS.filter((a) => a.startsWith('task.'))); + const SELF_KINDS = new Set(['task.created', 'task.assigned', 'task.state', 'task.closed', 'task.conflict']); + const POLL_KINDS = new Set([ +@@ -328,6 +332,59 @@ export class Broker { + return { ...r, fields, priority, requirement: requirement.get(taskId(r.task_ref)) ?? null }; + }); + } ++ // Row 89 (#1583): with no arguments, every row as #taskRows gives it, which the Console and ++ // `mosaic tasks` read. `task_ref`, alone, gives that one row. Any other argument filters and pages ++ // the short form in task_ref order: no description, and the assignees that are a role's tracker ++ // bot, by role. A tombstone isn't on the board, so a filtered read leaves it out. `total` counts ++ // every task the filters match; `next` is the `after` for the next page, null on the last. ++ #tasks(business, a) { ++ const rows = this.#taskRows(business); ++ if (!Object.keys(a).length) return rows; ++ if ('task_ref' in a) { ++ if (Object.keys(a).length !== 1 || !taskRef(a.task_ref)) fail('invalid-request'); ++ return rows.find((r) => r.task_ref === a.task_ref) ?? fail('task-not-found'); ++ } ++ const roles = this.#business(business).roles ?? {}; ++ const positive = (x, max = Number.MAX_SAFE_INTEGER) => Number.isSafeInteger(x) && x >= 1 && x <= max; ++ if ( ++ ('done' in a && typeof a.done !== 'boolean') || ++ ('assignee' in a && !(typeof a.assignee === 'string' && Object.hasOwn(roles, a.assignee))) || ++ ('label' in a && !positive(a.label)) || ++ ('requirement' in a && !(typeof a.requirement === 'string' && /^REQ-[A-Z]+-[1-9][0-9]*$/.test(a.requirement))) || ++ ('limit' in a && !positive(a.limit, TASK_PAGE_MAX)) || ++ ('after' in a && !taskRef(a.after)) ++ ) ++ fail('invalid-request'); ++ const bots = new Map( ++ Object.entries(roles) ++ .filter(([, r]) => r.tracker?.botId !== undefined) ++ .map(([role, r]) => [r.tracker.botId, role]), ++ ); ++ const matched = rows ++ .filter((r) => !r.fields.gone) ++ .map(({ task_ref, fields: f, priority, requirement, at }) => ({ ++ task_ref, ++ title: f.title ?? null, ++ done: Boolean(f.done), ++ bucket: f.bucket ?? null, ++ assignees: (f.assignees ?? []).filter((u) => bots.has(u)).map((u) => bots.get(u)).sort(), ++ labels: f.labels ?? [], ++ priority, ++ requirement, ++ changed_at: at, ++ })) ++ .filter( ++ (t) => ++ (!('done' in a) || t.done === a.done) && ++ (!('assignee' in a) || t.assignees.includes(a.assignee)) && ++ (!('label' in a) || t.labels.includes(a.label)) && ++ (!('requirement' in a) || t.requirement === a.requirement), ++ ); ++ const rest = 'after' in a ? matched.filter((t) => t.task_ref > a.after) : matched; ++ const limit = a.limit ?? TASK_PAGE; ++ const tasks = rest.slice(0, limit); ++ return { tasks, total: matched.length, next: rest.length > limit ? tasks.at(-1).task_ref : null }; ++ } + #checkAuthority(s, action, { decision = null, target = null } = {}) { + this.#agent(s); + if ( +@@ -841,7 +898,8 @@ export class Broker { + case 'agents': + case 'tasks': + case 'trail': { +- keys(a, verb === 'trail' ? ['subject'] : [], verb === 'trail' ? ['subject'] : []); ++ const allowed = verb === 'trail' ? ['subject'] : verb === 'tasks' ? TASK_ARGS : []; ++ keys(a, allowed, verb === 'trail' ? ['subject'] : []); + if (!s.human && !s.reader) this.#agent(s); + if (verb === 'inbox') + return this.#store +@@ -859,7 +917,7 @@ export class Broker { + // A role session gets the claims only: another role's decisions aren't its to read. + return s.human || s.reader ? claims.map((c) => this.#agentState(c)) : claims; + } +- if (verb === 'tasks') return this.#taskRows(s.business); ++ if (verb === 'tasks') return this.#tasks(s.business, a); + return this.#trail(s, identifier(a.subject)); + } + // Row 57 (#1546): the Inbox's "Routed to roles" and "Recent" lists, for human and +diff --git a/packages/bus/tests/task-page.test.mjs b/packages/bus/tests/task-page.test.mjs +new file mode 100644 +index 00000000..0cfa0598 +--- /dev/null ++++ b/packages/bus/tests/task-page.test.mjs +@@ -0,0 +1,225 @@ ++import test from 'node:test'; ++import assert from 'node:assert/strict'; ++import { mkdtempSync, rmSync } from 'node:fs'; ++import { tmpdir } from 'node:os'; ++import { join } from 'node:path'; ++import { Store } from '../src/store.mjs'; ++import { Broker, TASK_PAGE, TASK_PAGE_MAX } from '../src/broker.mjs'; ++import { REPLY_CAP } from '../../harness/src/tools.mjs'; ++// Row 89 (#1583): `tasks` with arguments filters and pages a short form a session can read; with ++// none it is the whole board, as the Console and `mosaic tasks` read it. ++const authority = { withinRole: ['message.send', 'task.create'], crossRole: [] }; ++const roles = { ++ pm: { authority, tracker: { bot: 'bot-pm', botId: 101 } }, ++ coder: { authority, tracker: { bot: 'bot-coder', botId: 102 } }, ++ reviewer: { authority, tracker: null }, ++}; ++const businesses = { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'pm', delivery: 'pm' }, roles } }; ++const at = '2026-10-12T12:00:00.000Z'; ++const digest = 'd'.repeat(64); ++const N = 500; ++const words = 'the board holds this task so a session can page through it without the description '.repeat(30); ++const ref = (i) => `vikunja:3/${i}`; ++// Task i: done when i % 5 == 0; the pm's bot on i % 3 == 0, the coder's on i % 4 == 0, a person (7) ++// on i % 2 == 0; label 9 on i % 7 == 0; tombstones at 13 and 26. ++const fieldsOf = (i) => ({ ++ project_id: 3, ++ title: `Task ${i}: a title of ordinary length for a tracker card`, ++ description: `

${i} ${words}

`, ++ done: i % 5 === 0, ++ due_date: null, ++ priority: i % 4, ++ percent_done: 0, ++ bucket: i % 5 === 0 ? 12 : 11, ++ labels: i % 7 === 0 ? [9, 4] : [4], ++ assignees: [...(i % 3 === 0 ? [101] : []), ...(i % 4 === 0 ? [102] : []), ...(i % 2 === 0 ? [7] : [])], ++}); ++const GONE = new Set([13, 26]); ++ ++function setup(t) { ++ const root = mkdtempSync(join(tmpdir(), 'bus-task-page-')); ++ const store = new Store(root); ++ const b = new Broker({ store, businesses }); ++ t.after(() => { ++ store.close(); ++ rmSync(root, { recursive: true, force: true }); ++ }); ++ const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }); ++ const pm = b.bindLaunch({ business: 'demo', role: 'pm', run: 'demo-pm', harness: 'claude-code' }); ++ b.request(pm, { verb: 'role.claim' }); ++ b.recordTask({ ++ business: 'demo', ++ snapshots: Array.from({ length: N }, (_, k) => k + 1).map((i) => ({ ++ task_ref: ref(i), ++ updated: at, ++ digest, ++ fields: GONE.has(i) ? { gone: 'no-access' } : fieldsOf(i), ++ via: GONE.has(i) ? 'task' : 'board', ++ read_at: at, ++ })), ++ }); ++ // Task 2 and task 6 were created by the stack, under two requirements. ++ for (const [i, requirement] of [[2, 'REQ-SLICE-1'], [6, 'REQ-SLICE-2']]) { ++ const { request } = b.request(human, { verb: 'message.send', args: { to: 'pm', body: 'please' } }); ++ b.recordTask({ ++ cap: pm, ++ business: 'demo', ++ snapshots: [{ task_ref: ref(i), updated: at, digest, fields: fieldsOf(i) }], ++ events: [{ kind: 'task.created', subject: ref(i), body: { request, requirement } }], ++ }); ++ } ++ const tasks = (args, cap = pm) => b.request(cap, { verb: 'tasks', args }); ++ return { b, human, pm, reader: b.bindReader({ business: 'demo' }), tasks }; ++} ++const refusesWith = (code) => (e) => e.code === code; ++// Every task, in the broker's order (task_ref, as text). ++const ALL = Array.from({ length: N }, (_, k) => k + 1).sort((x, y) => (ref(x) < ref(y) ? -1 : 1)); ++const live = (keep) => ALL.filter((i) => !GONE.has(i) && keep(i)).map(ref); ++// Every page of a read, by following next. ++function pages(tasks, args) { ++ const out = []; ++ let after; ++ for (;;) { ++ const page = tasks(after === undefined ? args : { ...args, after }); ++ out.push(page); ++ if (page.next === null) return out; ++ after = page.next; ++ } ++} ++ ++test('the session default fits under the reply cap and pages to the end with next', (t) => { ++ const { tasks } = setup(t); ++ const whole = tasks({}); ++ assert.equal(whole.length, N); ++ assert.ok(JSON.stringify(whole).length > 20 * REPLY_CAP, 'the whole board is far over the cap'); ++ const read = pages(tasks, { done: false }); ++ const open = live((i) => i % 5 !== 0); ++ assert.equal(read.length, Math.ceil(open.length / TASK_PAGE)); ++ for (const page of read) { ++ assert.ok(JSON.stringify(page).length < REPLY_CAP, `${JSON.stringify(page).length}`); ++ assert.equal(page.total, open.length); ++ assert.ok(page.tasks.length <= TASK_PAGE); ++ } ++ assert.deepEqual(read.flatMap((p) => p.tasks.map((x) => x.task_ref)), open); ++ assert.deepEqual(read.slice(0, -1).map((p) => p.next), read.slice(0, -1).map((p) => p.tasks.at(-1).task_ref)); ++ assert.equal(read.at(-1).next, null); ++ // limit at its most pages the same tasks in fewer pages. ++ const big = pages(tasks, { done: false, limit: TASK_PAGE_MAX }); ++ assert.equal(big.length, Math.ceil(open.length / TASK_PAGE_MAX)); ++ assert.deepEqual(big.flatMap((p) => p.tasks.map((x) => x.task_ref)), open); ++}); ++ ++test('the short form: no description, assignees by role, every listed key', (t) => { ++ const { tasks } = setup(t); ++ const whole = new Map(tasks({}).map((r) => [r.task_ref, r])); ++ const page = tasks({ limit: TASK_PAGE_MAX }); ++ for (const x of page.tasks) { ++ const row = whole.get(x.task_ref); ++ const i = Number(x.task_ref.split('/')[1]); ++ assert.deepEqual(x, { ++ task_ref: row.task_ref, ++ title: row.fields.title, ++ done: row.fields.done, ++ bucket: row.fields.bucket, ++ assignees: [...(i % 4 === 0 ? ['coder'] : []), ...(i % 3 === 0 ? ['pm'] : [])], ++ labels: row.fields.labels, ++ priority: row.priority, ++ requirement: row.requirement, ++ changed_at: row.at, ++ }); ++ } ++ const two = page.tasks.find((x) => x.task_ref === ref(2)); ++ assert.equal(two.requirement, 'REQ-SLICE-1'); ++ assert.equal(JSON.stringify(page).includes('description'), false); ++ // A tombstone is on the whole board, never in a filtered read. ++ assert.deepEqual(whole.get(ref(13)).fields, { gone: 'no-access' }); ++ const every = pages(tasks, { limit: TASK_PAGE_MAX }).flatMap((p) => p.tasks.map((x) => x.task_ref)); ++ assert.deepEqual(every, live(() => true)); ++}); ++ ++test('each filter, alone and together, and total counts every match', (t) => { ++ const { tasks } = setup(t); ++ const refs = (args) => pages(tasks, { ...args, limit: TASK_PAGE_MAX }).flatMap((p) => p.tasks.map((x) => x.task_ref)); ++ assert.deepEqual(refs({ done: true }), live((i) => i % 5 === 0)); ++ assert.deepEqual(refs({ done: false }), live((i) => i % 5 !== 0)); ++ assert.deepEqual(refs({ assignee: 'pm' }), live((i) => i % 3 === 0)); ++ assert.deepEqual(refs({ assignee: 'coder' }), live((i) => i % 4 === 0)); ++ // A role with no tracker bot holds no task; a person's assignment is no role's. ++ assert.deepEqual(refs({ assignee: 'reviewer' }), []); ++ assert.deepEqual(refs({ label: 9 }), live((i) => i % 7 === 0)); ++ assert.deepEqual(refs({ label: 4 }), live(() => true)); ++ assert.deepEqual(refs({ label: 5 }), []); ++ assert.deepEqual(refs({ requirement: 'REQ-SLICE-2' }), [ref(6)]); ++ assert.deepEqual(refs({ requirement: 'REQ-NONE-1' }), []); ++ assert.deepEqual(refs({ done: false, assignee: 'pm', label: 9 }), live((i) => i % 5 !== 0 && i % 3 === 0 && i % 7 === 0)); ++ const page = tasks({ done: true, assignee: 'coder', limit: 3 }); ++ assert.equal(page.total, live((i) => i % 5 === 0 && i % 4 === 0).length); ++ assert.equal(page.tasks.length, 3); ++ assert.equal(page.next, page.tasks[2].task_ref); ++ // after needn't be a task on the board, or one the filters match: the page starts past it, in ++ // text order (3/500 < 3/501 < 3/51), and total still counts every match. ++ const past = tasks({ after: 'vikunja:3/501', limit: 2 }); ++ assert.deepEqual(past.tasks.map((x) => x.task_ref), [ref(51), ref(52)]); ++ assert.equal(past.total, live(() => true).length); ++ assert.deepEqual(tasks({ done: true, after: ref(51), limit: 1 }).tasks.map((x) => x.task_ref), [ref(55)]); ++ const last = tasks({ after: 'vikunja:9/1' }); ++ assert.deepEqual(last, { tasks: [], total: live(() => true).length, next: null }); ++ // A page that ends exactly on the last match has no next. ++ const exact = tasks({ requirement: 'REQ-SLICE-1', limit: 1 }); ++ assert.deepEqual([exact.tasks.length, exact.next], [1, null]); ++}); ++ ++test('task_ref reads one task in full, as the whole board has it', (t) => { ++ const { tasks, human, reader } = setup(t); ++ const whole = tasks({}); ++ for (const r of [ref(2), ref(13), ref(500)]) assert.deepEqual(tasks({ task_ref: r }), whole.find((x) => x.task_ref === r)); ++ assert.ok(tasks({ task_ref: ref(2) }).fields.description.length > 2000); ++ assert.deepEqual(tasks({ task_ref: ref(2) }, human), tasks({ task_ref: ref(2) }, reader)); ++ assert.throws(() => tasks({ task_ref: 'vikunja:3/501' }), refusesWith('task-not-found')); ++ assert.throws(() => tasks({ task_ref: 'vikunja:4/2' }), refusesWith('task-not-found')); ++}); ++ ++test('no arguments is the read the Console and mosaic make, unchanged', (t) => { ++ const { b, tasks, human, reader } = setup(t); ++ const rows = b.taskView('demo', 'current'); ++ const whole = tasks({}); ++ assert.deepEqual(whole.map(({ priority, requirement, ...r }) => r), rows); ++ assert.deepEqual(tasks({}, human), whole); ++ assert.deepEqual(tasks({}, reader), whole); ++ assert.deepEqual(b.request(reader, { verb: 'tasks' }), whole); ++}); ++ ++test('an unknown argument or a bad value refuses invalid-request', (t) => { ++ const { tasks, reader } = setup(t); ++ const bad = [ ++ { status: 'open' }, ++ { description: 'x' }, ++ { done: 'false' }, ++ { done: 0 }, ++ { done: null }, ++ { assignee: 'cto' }, ++ { assignee: 'toString' }, ++ { assignee: 101 }, ++ { label: 0 }, ++ { label: -1 }, ++ { label: 1.5 }, ++ { label: '9' }, ++ { requirement: 'REQ-slice-1' }, ++ { requirement: 'REQ-SLICE-0' }, ++ { requirement: 7 }, ++ { limit: 0 }, ++ { limit: TASK_PAGE_MAX + 1 }, ++ { limit: 2.5 }, ++ { limit: '50' }, ++ { after: 'vikunja:3' }, ++ { after: 3 }, ++ { task_ref: 'vikunja:3/x' }, ++ { task_ref: ref(2), done: false }, ++ { task_ref: ref(2), limit: 1 }, ++ ]; ++ for (const args of bad) { ++ assert.throws(() => tasks(args), refusesWith('invalid-request'), JSON.stringify(args)); ++ assert.throws(() => tasks(args, reader), refusesWith('invalid-request'), JSON.stringify(args)); ++ } ++ assert.deepEqual([TASK_PAGE, TASK_PAGE_MAX], [50, 200]); ++}); +diff --git a/packages/harness/README.md b/packages/harness/README.md +index 7598a573..1d1d7c5b 100644 +--- a/packages/harness/README.md ++++ b/packages/harness/README.md +@@ -41,7 +41,10 @@ writes the bundle once into `dir` (0700; every file 0600, created with `wx`, + so an existing bundle is never overwritten): + + - `prompt.md`: the role contract, then a generated "This session" section +- (business, instance, run, who launched it, workspace, typed tools). ++ (business, instance, run, who launched it, workspace, built-in and typed ++ tools). The built-in tools are named as the harness names them, as the ++ manifest lists them: `limits.tools` for Pi, `claudeBuiltins` for Claude ++ Code (Read, Write, Edit, Grep, Glob). + - `policy.json`: harness, workspace, built-in tools (`limits.tools`), typed + tool names, network, the services the role needs, and the credential + status the host read from the broker (metadata only, never a token). +@@ -71,6 +74,20 @@ The harness never holds the capability. Tools call the runner's tool socket + against `tools.json` and forwards the call with its own capability. A + refusal comes back to the model as `refused: `. + ++A reply goes to the model as compact JSON, from the MCP server and the Pi ++extension alike (`replyText` in `tools.mjs`). Over `REPLY_CAP`, 40000 ++characters, the tool returns an error that names the reply's size and the ++cap and asks the session to narrow the query, never a cut-off reply. The ++pinned Claude CLI (2.1.296) saves an MCP result over 50000 characters to a ++file outside the workspace, which the gate refuses to read (row 89, #1583; ++`agents/dewey/work/queue-89/build.md` has how that size was found). ++ ++`list_tasks` (tracker holders) takes the broker's `tasks` filters, `done`, ++`assignee`, `label`, `requirement`, `limit` and `after`, and sends ++`done: false` when the session leaves `done` out, so its default is open ++tasks, 50 at a time, without descriptions. `get_task` takes a `task_ref` ++and returns that task in full. `packages/bus/README.md` has the reply. ++ + ## The gate + + `gate.mjs` `decide(policy, tool, input)` answers one tool call: the +@@ -188,6 +205,13 @@ These are limits, not bugs, and nothing in this package claims otherwise. + - **Resolution** runs without the project layer, there is no skills source + (bundles list none), and the resolved `thinking` level is recorded in the + manifest but not applied to either harness. ++- **The reply cap is per reply.** Claude Code also persists the largest ++ results of one message when their total passes 200000 characters, so ++ six or more replies near the cap in one turn can still be saved to a ++ file. A single task whose full row is over the cap (a description near ++ 40000 characters) can't be read through `get_task`. The cap matches ++ Claude CLI 2.1.296; the binary the bus unit names updates itself, so a ++ later version can move the threshold. + - **`--restricted`** (Claude Code) is not one of the S0 lines. It is what + keeps `CLAUDE.md` files and auto-memory out of the prompt (above). + - **Other spellings.** A read is refused when a spelling Pi might open +diff --git a/packages/harness/src/bundle.mjs b/packages/harness/src/bundle.mjs +index 2f16b1c8..9326be87 100644 +--- a/packages/harness/src/bundle.mjs ++++ b/packages/harness/src/bundle.mjs +@@ -64,7 +64,8 @@ function prompt({ contract, resolved, session, tools, untyped }) { + `- Business: ${resolved.business}. Role instance: ${resolved.instance} (definition ${resolved.definition}). Run: ${session.run}.`, + `- Launched by: ${session.launchedBy}. Harness: ${session.harness}, model ${session.model}.`, + `- Workspace: ${session.workspace}. File tools work inside it only.`, +- `- Built-in tools: ${resolved.limits.tools.length ? resolved.limits.tools.join(", ") : "none"}.`, ++ // Row 89 (#1583): the names the harness gives its built-ins, as the manifest lists them. ++ `- Built-in tools: ${(session.harness === "pi" ? resolved.limits.tools : claudeBuiltins({ tools: resolved.limits.tools })).join(", ") || "none"}.`, + `- Bus tools: ${tools.map((t) => t.name).join(", ")}. The broker decides every call; a refusal names its reason.`, + "- Each message to you arrives as one turn. Your final answer goes back to the sender as the reply, so end each turn with the answer itself.", + "- You never hold a token. You act on the tracker, the forge and other roles only through the bus tools.", +diff --git a/packages/harness/src/mcp-server.mjs b/packages/harness/src/mcp-server.mjs +index 0f43948e..5415a55e 100644 +--- a/packages/harness/src/mcp-server.mjs ++++ b/packages/harness/src/mcp-server.mjs +@@ -1,11 +1,12 @@ + // Minimal stdio MCP server that gives a Claude Code session its typed + // tools: `node mcp-server.mjs `. Newline-delimited + // JSON-RPC 2.0 with initialize, tools/list, tools/call and ping. Each call +-// goes to the runner's tool socket; the server holds no capability. ++// goes to the runner's tool socket; the server holds no capability. A ++// reply over REPLY_CAP is a tool error (tools.mjs, replyText). + + import { readFileSync } from "node:fs"; + import { createInterface } from "node:readline"; +-import { callTool } from "./tools.mjs"; ++import { callTool, replyText } from "./tools.mjs"; + + const [toolsFile, socket] = process.argv.slice(2); + if (!toolsFile || !socket) { +@@ -37,7 +38,7 @@ async function handle(m) { + if (!byName.has(name)) return error(id, -32602, `unknown tool: ${name}`); + try { + const result = await callTool(socket, name, m.params?.arguments ?? {}); +- return reply(id, { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }); ++ return reply(id, { content: [{ type: "text", text: replyText(result) }] }); + } catch (e) { + return reply(id, { content: [{ type: "text", text: e.message }], isError: true }); + } +diff --git a/packages/harness/src/pi-extension.mjs b/packages/harness/src/pi-extension.mjs +index 2b3b0044..5647f0f5 100644 +--- a/packages/harness/src/pi-extension.mjs ++++ b/packages/harness/src/pi-extension.mjs +@@ -10,7 +10,7 @@ + + import { readFileSync, writeFileSync } from "node:fs"; + import { decide } from "./gate.mjs"; +-import { callTool } from "./tools.mjs"; ++import { callTool, replyText } from "./tools.mjs"; + + function need(name) { + const value = process.env[name]; +@@ -40,9 +40,9 @@ export default function (pi) { + description: t.description, + parameters: t.parameters, + async execute(_toolCallId, params) { +- // A refusal throws, which pi reports to the model as a tool error. ++ // A refusal, or a reply over REPLY_CAP, throws, which pi reports to the model as a tool error. + const result = await callTool(socket, t.name, params ?? {}); +- return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }], details: {} }; ++ return { content: [{ type: "text", text: replyText(result) }], details: {} }; + }, + }); + } +diff --git a/packages/harness/src/tools.mjs b/packages/harness/src/tools.mjs +index 903f3bef..f0011d30 100644 +--- a/packages/harness/src/tools.mjs ++++ b/packages/harness/src/tools.mjs +@@ -16,6 +16,11 @@ const str = (description, extra = {}) => ({ type: "string", description, ...extr + const obj = (properties, required = []) => ({ type: "object", properties, required, additionalProperties: false }); + + const TASK_REF = str("Task reference, as list_tasks shows it"); ++// The broker's page size for `tasks`, by default and at most (TASK_PAGE and TASK_PAGE_MAX in ++// packages/bus/src/broker.mjs; a test holds them equal). Kept here so the session's tool server ++// doesn't load the broker. ++const TASK_PAGE = 50; ++const TASK_PAGE_MAX = 200; + const COMMON = { + task_ref: TASK_REF, + decision: str("Id of a resolved decision that approves this call; needed when the action is cross-role or gated for you"), +@@ -113,10 +118,39 @@ export function typedTools({ resolved, arbiter = false, tracker = false }) { + add("list_agents", "agents", null, "List the role instances that hold a claim now.", obj({})); + add("inbox", "inbox", null, "List the open decisions routed to you.", obj({})); + add("trail", "trail", null, "Show the evidence trail for a subject: a run, message, decision or task.", obj({ subject: str("Subject id") }, ["subject"])); +- if (tracker) add("list_tasks", "tasks", null, "List the business's tracker tasks as the broker last saw them.", obj({})); ++ if (tracker) { ++ add("list_tasks", "tasks", null, `List the business's tracker tasks as the broker last saw them, in short form with no description. By default open tasks (done false), ${TASK_PAGE} at a time; pass the reply's next as after for the next page. get_task reads one task in full.`, obj({ ++ done: { type: "boolean", description: "true for closed tasks, false (the default) for open ones" }, ++ assignee: str("Only tasks assigned to this role"), ++ label: { type: "integer", minimum: 1, description: "Only tasks with this label id" }, ++ requirement: str("Only tasks created for this requirement id", { pattern: "^REQ-[A-Z]+-[1-9][0-9]*$" }), ++ limit: { type: "integer", minimum: 1, maximum: TASK_PAGE_MAX, description: `Tasks per page, ${TASK_PAGE} by default` }, ++ after: str("The next value of the previous page"), ++ })); ++ add("get_task", "tasks", null, "Read one tracker task in full, description included.", obj({ task_ref: TASK_REF }, ["task_ref"])); ++ } + return tools; + } + ++// Arguments a tool sends when the session leaves them out. list_tasks always filters: the ++// broker's `tasks` with no arguments is the whole board, which is the Console's read. ++const ARG_DEFAULTS = { list_tasks: { done: false } }; ++ ++// The most characters a tool reply may hold. The pinned Claude CLI (2.1.296, decision 91) saves ++// an MCP result over 50000 characters to a file outside the workspace, which the run's file guard ++// refuses to read (#1583; agents/dewey/work/queue-89/build.md has how that was found). The cap keeps ++// 20% under it, counts as JavaScript does (UTF-16 units, as the CLI does), and holds for pi too. ++export const REPLY_CAP = 40_000; ++ ++// The text a tool returns to the model: compact JSON, or an error over the cap. A reply is never ++// cut short. ++export function replyText(result) { ++ const text = JSON.stringify(result) ?? "null"; ++ if (text.length > REPLY_CAP) ++ throw new Error(`reply too large: ${text.length} characters, over the ${REPLY_CAP}-character cap. Narrow the query: a filter, a smaller limit, or get_task for one task.`); ++ return text; ++} ++ + // Held actions that have no typed tool. The manifest lists them so a + // reviewer sees what the session can't do through the bus. + export function untypedActions(resolved, tools) { +@@ -137,7 +171,7 @@ export function callTool(path, tool, args = {}, { timeout = 60_000 } = {}) { + error ? reject(error) : resolve(value); + }; + socket.setTimeout(timeout, () => end(new Error("tool call timed out"))); +- socket.on("connect", () => socket.write(JSON.stringify({ tool, args }) + "\n")); ++ socket.on("connect", () => socket.write(JSON.stringify({ tool, args: { ...ARG_DEFAULTS[tool], ...args } }) + "\n")); + socket.on("error", (e) => end(new Error(`tool socket: ${e.code ?? e.message}`))); + socket.on("end", () => end(new Error("tool socket closed without a reply"))); + socket.on("data", (b) => { +diff --git a/packages/harness/tests/bundle.test.mjs b/packages/harness/tests/bundle.test.mjs +index 23deabf2..98cee8ed 100644 +--- a/packages/harness/tests/bundle.test.mjs ++++ b/packages/harness/tests/bundle.test.mjs +@@ -87,6 +87,20 @@ test("a claude-code bundle adds the wrapped gate hook and the MCP config", (t) = + assert.deepEqual(Object.keys(m.sources).sort(), ["claudeGate", "gate", "mcpServer", "tools"]); + }); + ++// Row 89 (#1583): the prompt names the built-ins as the session's harness names them. ++test("the prompt's built-in tools line is the harness's own names, as the manifest lists them", (t) => { ++ const line = (b) => readFileSync(b.files.prompt, "utf8").split("\n").find((l) => l.startsWith("- Built-in tools: ")); ++ const pi = build(t, resolvedFor(t, "pm")); ++ assert.equal(line(pi), "- Built-in tools: read, write, edit, grep, find, ls."); ++ const claude = build(t, resolvedFor(t, "pm", (d) => (d.roles.pm.vars = { ...d.roles.pm.vars, harness: "claude-code", model: "claude-opus-5-5" }))); ++ assert.equal(line(claude), "- Built-in tools: Read, Write, Edit, Grep, Glob."); ++ for (const b of [pi, claude]) assert.equal(line(b), `- Built-in tools: ${b.manifest.builtins.join(", ")}.`); ++ for (const harness of ["pi", "claude-code"]) { ++ const none = build(t, resolvedFor(t, "pm", (d) => (d.roles.pm.vars = { ...d.roles.pm.vars, harness, model: "claude-opus-5-5", "limits.tools": [] }))); ++ assert.equal(line(none), "- Built-in tools: none.", harness); ++ } ++}); ++ + test("a bundle is written once: an existing file refuses", (t) => { + const dir = scratch(t); + mkdirSync(join(dir, "bundle")); +diff --git a/packages/harness/tests/mcp-server.test.mjs b/packages/harness/tests/mcp-server.test.mjs +index 72992dde..2219730c 100644 +--- a/packages/harness/tests/mcp-server.test.mjs ++++ b/packages/harness/tests/mcp-server.test.mjs +@@ -5,7 +5,7 @@ import { writeFileSync } from "node:fs"; + import { join } from "node:path"; + import { createInterface } from "node:readline"; + import { SOURCES } from "../src/bundle.mjs"; +-import { typedTools } from "../src/tools.mjs"; ++import { REPLY_CAP, typedTools } from "../src/tools.mjs"; + import { fakeToolSocket, resolvedFor, scratch } from "./helpers.mjs"; + + // The server over stdio: send(message) resolves with the reply to its id. +@@ -43,6 +43,8 @@ async function setup(t) { + writeFileSync(toolsFile, JSON.stringify(tools)); + const sock = await fakeToolSocket(t, dir, (tool, args) => { + if (tool === "launch") throw new Error("capacity-full"); ++ // trail answers a string `size` characters long once JSON-encoded, for the reply cap. ++ if (tool === "trail" && /^size:/.test(args.subject)) return "x".repeat(Number(args.subject.slice(5)) - 2); + return { tool, args }; + }); + return { dir, tools, sock, server: serve(t, toolsFile, sock.path) }; +@@ -70,6 +72,25 @@ test("tools/call goes through the tool socket; a refusal is an isError result", + assert.deepEqual(sock.calls.map((c) => c.tool), ["list_agents", "launch"]); + }); + ++test("replies are compact JSON; over the cap a tool error names the size and the cap", async (t) => { ++ const { sock, server } = await setup(t); ++ const call = async (name, args) => (await server.send("tools/call", { name, arguments: args })).result; ++ const ok = await call("get_task", { task_ref: "vikunja:3/7" }); ++ assert.equal(ok.content[0].text, '{"tool":"get_task","args":{"task_ref":"vikunja:3/7"}}'); ++ // list_tasks with no arguments asks the broker for open tasks. ++ assert.equal((await call("list_tasks", {})).content[0].text, '{"tool":"list_tasks","args":{"done":false}}'); ++ const fits = await call("trail", { subject: `size:${REPLY_CAP}` }); ++ assert.equal(fits.isError, undefined); ++ assert.equal(fits.content[0].text.length, REPLY_CAP); ++ const over = await call("trail", { subject: `size:${REPLY_CAP + 1}` }); ++ assert.equal(over.isError, true); ++ assert.deepEqual(over.content, [{ ++ type: "text", ++ text: `reply too large: ${REPLY_CAP + 1} characters, over the ${REPLY_CAP}-character cap. Narrow the query: a filter, a smaller limit, or get_task for one task.`, ++ }]); ++ assert.deepEqual(sock.calls.map((c) => c.tool), ["get_task", "list_tasks", "trail", "trail"]); ++}); ++ + test("unknown tools and methods are JSON-RPC errors and never reach the socket", async (t) => { + const { sock, server } = await setup(t); + const unknown = await server.send("tools/call", { name: "resolve_decision", arguments: {} }); +diff --git a/packages/harness/tests/pi-session.test.mjs b/packages/harness/tests/pi-session.test.mjs +index 32db2aa3..95954b9d 100644 +--- a/packages/harness/tests/pi-session.test.mjs ++++ b/packages/harness/tests/pi-session.test.mjs +@@ -8,6 +8,7 @@ import { existsSync, mkdirSync, readdirSync, symlinkSync, writeFileSync } from " + import { join } from "node:path"; + import { buildBundle } from "../src/bundle.mjs"; + import { adapterEnv } from "../src/runner.mjs"; ++import { REPLY_CAP } from "../src/tools.mjs"; + import { fakeToolSocket, mockAnthropic, REPO, resolvedFor, scratch } from "./helpers.mjs"; + + const ADAPTER = join(REPO, "adapters", "pi", "adapter.sh"); +@@ -25,6 +26,8 @@ async function session(t, script, place = (dir) => join(dir, "ws")) { + const resolved = resolvedFor(t, "pm", (d) => (d.roles.pm.vars = { ...d.roles.pm.vars, model: "probe-model" })); + const sock = await fakeToolSocket(t, dir, (tool, args) => { + if (tool === "launch") throw new Error("capacity-full"); ++ // trail answers a string `size` characters long once JSON-encoded, for the reply cap. ++ if (tool === "trail" && /^size:/.test(args.subject)) return "x".repeat(Number(args.subject.slice(5)) - 2); + return { tool, args }; + }); + const s = { +@@ -78,7 +81,7 @@ test("pi: typed tools reach the socket, the gate blocks, agent_end writes the ma + assert.equal(results.length, 5); + // list_agents went through the socket; launch was refused by it. + assert.equal(results[0][0], false); +- assert.match(results[0][1], /"tool": "list_agents"/); ++ assert.match(results[0][1], /^\{"tool":"list_agents"/); + assert.equal(results[1][0], true); + assert.match(results[1][1], /refused: capacity-full/); + // read inside passes; outside is gated; bash isn't in the PM's tools. +@@ -96,6 +99,30 @@ test("pi: typed tools reach the socket, the gate blocks, agent_end writes the ma + assert.ok(readdirSync(s.sessionDir).length > 0); + }); + ++test("pi: a reply over the cap is a tool error naming the size and the cap; list_tasks asks for open tasks", async (t) => { ++ const { sock, s, env } = await session(t, [ ++ { name: "trail", input: { subject: `size:${REPLY_CAP}` } }, ++ { name: "trail", input: { subject: `size:${REPLY_CAP + 1}` } }, ++ { name: "list_tasks", input: {} }, ++ { name: "get_task", input: { task_ref: "vikunja:3/7" } }, ++ ]); ++ const r = await turn(env, "Message 1 from jason, class REQUEST:\n\nshow the board", s.workspace); ++ assert.equal(r.code, 0, r.stderr); ++ const results = JSON.parse(r.stdout.trim().slice("ANSWER ".length)); ++ assert.equal(results.length, 4); ++ assert.deepEqual(results[0], [false, `"${"x".repeat(79)}`]); ++ assert.equal(results[1][0], true); ++ assert.match(results[1][1], new RegExp(`^reply too large: ${REPLY_CAP + 1} characters, over the ${REPLY_CAP}-character cap`)); ++ assert.deepEqual(results[2], [false, '{"tool":"list_tasks","args":{"done":false}}']); ++ assert.deepEqual(results[3], [false, '{"tool":"get_task","args":{"task_ref":"vikunja:3/7"}}']); ++ assert.deepEqual(sock.calls.map((c) => [c.tool, c.args]), [ ++ ["trail", { subject: `size:${REPLY_CAP}` }], ++ ["trail", { subject: `size:${REPLY_CAP + 1}` }], ++ ["list_tasks", { done: false }], ++ ["get_task", { task_ref: "vikunja:3/7" }], ++ ]); ++}); ++ + test("pi: a write through a dangling symlink is blocked, and nothing appears outside", async (t) => { + const { dir, workspace, s, env } = await session(t, [ + { name: "write", input: { path: "notes.md", content: "planted\n" } }, +diff --git a/packages/harness/tests/tools.test.mjs b/packages/harness/tests/tools.test.mjs +index 3fe7dd1e..045f11c1 100644 +--- a/packages/harness/tests/tools.test.mjs ++++ b/packages/harness/tests/tools.test.mjs +@@ -1,6 +1,7 @@ + import { test } from "node:test"; + import assert from "node:assert/strict"; +-import { callTool, typedTools, untypedActions } from "../src/tools.mjs"; ++import { callTool, REPLY_CAP, replyText, typedTools, untypedActions } from "../src/tools.mjs"; ++import { TASK_PAGE, TASK_PAGE_MAX } from "../../bus/src/broker.mjs"; + import { fakeToolSocket, resolvedFor, scratch } from "./helpers.mjs"; + + const names = (tools) => tools.map((t) => t.name); +@@ -12,7 +13,7 @@ test("the PM gets launch, its task verbs and the reads", (t) => { + "send_message", "launch", + "task_create", "task_assign", "task_reassign", "task_schedule", "task_priority_change", "task_scope_change", + "task_update_assigned", "task_close", +- "raise_decision", "resolve_decision", "list_agents", "inbox", "trail", "list_tasks", ++ "raise_decision", "resolve_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task", + ]); + const launch = tools.find((x) => x.name === "launch"); + assert.equal(launch.verb, "launch"); +@@ -37,7 +38,7 @@ test("a coder with a tracker gets its own task tools and no task_reassign", (t) + const coder = resolvedFor(t, "coder"); + assert.deepEqual(names(typedTools({ resolved: coder, tracker: true })), [ + "send_message", "task_scope_change", "task_update_assigned", +- "raise_decision", "list_agents", "inbox", "trail", "list_tasks", ++ "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task", + ]); + }); + +@@ -49,7 +50,7 @@ test("launch only when the business's launch block names the instance as launche + + test("an action outside the instance's authority has no tool", (t) => { + const coder = resolvedFor(t, "coder", (d) => (d.roles.coder.vars["limits.authority"] = ["review.request"])); +- assert.deepEqual(names(typedTools({ resolved: coder, tracker: true })), ["raise_decision", "list_agents", "inbox", "trail", "list_tasks"]); ++ assert.deepEqual(names(typedTools({ resolved: coder, tracker: true })), ["raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task"]); + }); + + test("callTool: one JSON line out, the result back, a refusal rejects", async (t) => { +@@ -63,3 +64,54 @@ test("callTool: one JSON line out, the result back, a refusal rejects", async (t + await assert.rejects(callTool(`${dir}/missing.sock`, "x"), /tool socket: ENOENT/); + assert.deepEqual(sock.calls.map((c) => c.tool), ["list_agents", "launch"]); + }); ++ ++// Row 89 (#1583): task reads a session can page, and replies that fit. ++test("list_tasks takes the broker's filters and pages; get_task takes a task_ref", (t) => { ++ const tools = typedTools({ resolved: resolvedFor(t, "pm"), tracker: true }); ++ const list = tools.find((x) => x.name === "list_tasks"); ++ const get = tools.find((x) => x.name === "get_task"); ++ assert.deepEqual([list.verb, list.action, get.verb, get.action], ["tasks", null, "tasks", null]); ++ const p = list.parameters; ++ assert.deepEqual(Object.keys(p.properties), ["done", "assignee", "label", "requirement", "limit", "after"]); ++ assert.deepEqual(p.required, []); ++ assert.equal(p.properties.done.type, "boolean"); ++ assert.equal(p.properties.assignee.type, "string"); ++ assert.deepEqual([p.properties.label.type, p.properties.label.minimum], ["integer", 1]); ++ assert.equal(p.properties.requirement.pattern, "^REQ-[A-Z]+-[1-9][0-9]*$"); ++ assert.equal(p.properties.after.type, "string"); ++ // The page sizes are the broker's own. ++ assert.deepEqual([p.properties.limit.type, p.properties.limit.minimum, p.properties.limit.maximum], ["integer", 1, TASK_PAGE_MAX]); ++ assert.match(p.properties.limit.description, new RegExp(`\\b${TASK_PAGE} by default`)); ++ assert.match(list.description, new RegExp(`open tasks \\(done false\\), ${TASK_PAGE} at a time`)); ++ assert.match(list.description, /next as after/); ++ assert.match(list.description, /get_task/); ++ assert.deepEqual(get.parameters.properties, { task_ref: tools.find((x) => x.name === "task_close").parameters.properties.task_ref }); ++ assert.deepEqual(get.parameters.required, ["task_ref"]); ++ // Neither without a tracker. ++ assert.deepEqual(names(typedTools({ resolved: resolvedFor(t, "pm") })).filter((n) => n.endsWith("_tasks") || n === "get_task"), []); ++}); ++ ++test("callTool sends list_tasks done false unless the session names done; other tools as given", async (t) => { ++ const sock = await fakeToolSocket(t, scratch(t), (tool, args) => args); ++ assert.deepEqual(await callTool(sock.path, "list_tasks"), { done: false }); ++ assert.deepEqual(await callTool(sock.path, "list_tasks", { limit: 5 }), { done: false, limit: 5 }); ++ assert.deepEqual(await callTool(sock.path, "list_tasks", { done: true }), { done: true }); ++ assert.deepEqual(await callTool(sock.path, "get_task", { task_ref: "vikunja:3/7" }), { task_ref: "vikunja:3/7" }); ++ assert.deepEqual(await callTool(sock.path, "list_agents"), {}); ++}); ++ ++test("replyText: compact JSON up to the cap, an error naming the size and the cap over it", () => { ++ assert.equal(REPLY_CAP, 40_000); ++ assert.equal(replyText({ a: [1, { b: "c" }] }), '{"a":[1,{"b":"c"}]}'); ++ assert.equal(replyText(null), "null"); ++ assert.equal(replyText(undefined), "null"); ++ // A JSON string adds its two quotes; length counts UTF-16 units, as the CLI does. ++ assert.equal(replyText("x".repeat(REPLY_CAP - 2)).length, REPLY_CAP); ++ assert.throws(() => replyText("x".repeat(REPLY_CAP - 1)), (e) => { ++ assert.equal(e.message, `reply too large: ${REPLY_CAP + 1} characters, over the ${REPLY_CAP}-character cap. Narrow the query: a filter, a smaller limit, or get_task for one task.`); ++ return true; ++ }); ++ assert.throws(() => replyText("\u{1F600}".repeat(REPLY_CAP / 2)), /reply too large: 40002 characters/); ++ // 40000 units fit, though they are 80002 bytes: the cap isn't a byte count. ++ assert.equal(replyText("\u{1F600}".repeat(REPLY_CAP / 2 - 1)).length, REPLY_CAP); ++}); diff --git a/agents/dewey/work/queue-89/candidate-manifest.sha256 b/agents/dewey/work/queue-89/candidate-manifest.sha256 new file mode 100644 index 000000000..8ab2993e8 --- /dev/null +++ b/agents/dewey/work/queue-89/candidate-manifest.sha256 @@ -0,0 +1,12 @@ +81b985a837d1b5f837fb94faa2264684f6c1633e916e2ab99f57659bc2bd1a9f packages/bus/README.md +dbef704b063f04e0ad451cb1977ab2641a9daf37b12537d366d4f22b3299a639 packages/bus/src/broker.mjs +7a62dd8d948b576f5873e70c92eee8d51d8fbc69656ff482d30f25ba78d45e31 packages/bus/tests/task-page.test.mjs +f334d4c93ec207528775029d3d042922796e5cd950d30d3da55c14977947e525 packages/harness/README.md +dab8f6528a836cd8649d46de48b1eb0c06b16a2c0d802e0c22485cd755293812 packages/harness/src/bundle.mjs +ce0107c3b21e07925b6543b6b925da8b8768823448d4fcf30b620883cfd2d39e packages/harness/src/mcp-server.mjs +fcba4b8dfd9dde947a79f88376d880f383e2a04ee3c0a9ba2fa5af641fe61b35 packages/harness/src/pi-extension.mjs +7e8ba55bc5c00274a3c953f0afcde86801a624914f2c5babb97135f7aa561b89 packages/harness/src/tools.mjs +414254c1a60c528947ade47388d2a60b91f6964911b737cfeb310e85c97868eb packages/harness/tests/bundle.test.mjs +2a84dac2ab5330ef9c96ca1ea878936e09ce589571fe18196c4d2545309cd9d9 packages/harness/tests/mcp-server.test.mjs +d1c334a87157d8a8da7f4c592c965b75684dbaac901791341784490fa9b1c2f7 packages/harness/tests/pi-session.test.mjs +103b774d6a52f267b0c40f7a944d5a2e3732a2a36fd2c9b8a83d207ee063e1fa packages/harness/tests/tools.test.mjs diff --git a/agents/dewey/work/queue-89/gate-summary.txt b/agents/dewey/work/queue-89/gate-summary.txt new file mode 100644 index 000000000..872b620b5 --- /dev/null +++ b/agents/dewey/work/queue-89/gate-summary.txt @@ -0,0 +1,18 @@ +start 2026-10-12T20:13:35Z HEAD-base a3f6e9e66cc1961becb2c4a0ff87fdbdff5f5edc +node bus rc=0 ℹ tests 108 ℹ pass 108 ℹ fail 0 +node harness rc=0 ℹ tests 65 ℹ pass 65 ℹ fail 0 +node webui rc=0 ℹ tests 84 ℹ pass 84 ℹ fail 0 +node cli rc=0 ℹ tests 115 ℹ pass 115 ℹ fail 0 +node tasks rc=0 ℹ tests 74 ℹ pass 74 ℹ fail 0 +node control-board rc=0 ℹ tests 124 ℹ pass 124 ℹ fail 0 +test-auth rc=0 selftest: 15 passed, 0 failed +test-conductor rc=0 selftest: 17 passed, 0 failed +test-config rc=0 selftest: 24 passed, 0 failed +test-discord rc=0 discord suite: 66 passed, 0 failed +test-extension-package rc=0 extension package selftest: 18 passed, 0 failed +test-foundation rc=0 OK differential schema oracle: PASS: differential schema oracle (finite corpus; compatibility evidence, not equivalence proof) selftest: 44 passed, 0 failed +test-queue rc=0 queue suite: 27 passed, 0 failed +test-release rc=0 selftest: 14 passed, 0 failed +test-task rc=0 selftest: 26 passed, 0 failed +end 2026-10-12T20:18:12Z +rerun 2026-10-12T20:43:56Z to 2026-10-12T20:44:07Z, final candidate (tools.test.mjs gained one assertion after the mutants): node harness rc=0 ℹ tests 65 ℹ pass 65 ℹ fail 0 diff --git a/agents/dewey/work/queue-89/gate.sh b/agents/dewey/work/queue-89/gate.sh new file mode 100755 index 000000000..e6fc2fc2e --- /dev/null +++ b/agents/dewey/work/queue-89/gate.sh @@ -0,0 +1,18 @@ +#!/bin/bash +# Row 89 gate: sequential, in the candidate worktree, output teed per suite. +cd $HOME/dewey-scratch/r89 || exit 9 +export TMPDIR=$HOME/dewey-scratch/tmp +OUT=$HOME/dewey-scratch/r89-out/gate +mkdir -p $OUT +S=$OUT/../gate-summary.txt +echo "start $(date -u +%Y-%m-%dT%H:%M:%SZ) HEAD-base $(git rev-parse HEAD)" > $S +for p in bus harness webui cli tasks control-board; do + node --test "packages/$p/tests/*.test.mjs" > $OUT/node-$p.log 2>&1; rc=$? + echo "node $p rc=$rc $(grep -E '^ℹ (tests|pass|fail)' $OUT/node-$p.log | tr '\n' ' ')" >> $S +done +for f in scripts/test-*.sh; do + n=$(basename $f .sh) + if [ "$n" = test-task ]; then DOCKER_HOST=unix:///nonexistent bash $f > $OUT/$n.log 2>&1; else bash $f > $OUT/$n.log 2>&1; fi; rc=$? + echo "$n rc=$rc $(grep -iE '[0-9]+ (passed|failed)|pass(ed)?:|fail(ed)?:' $OUT/$n.log | tail -2 | tr '\n' ' ')" >> $S +done +echo "end $(date -u +%Y-%m-%dT%H:%M:%SZ)" >> $S diff --git a/agents/dewey/work/queue-89/mutants-run1.txt b/agents/dewey/work/queue-89/mutants-run1.txt new file mode 100644 index 000000000..f3f127097 --- /dev/null +++ b/agents/dewey/work/queue-89/mutants-run1.txt @@ -0,0 +1,33 @@ +start 2026-10-12T20:18:19Z +killed no args filtered too +killed tombstones kept +killed next on an exact last page +killed default limit ignored +killed limit max unchecked +killed assignee by raw id +killed assignees keep persons +killed task_ref with other args +killed missing task_ref is null +killed done check dropped +killed done filter dropped +killed assignee any string +killed label check dropped +killed label filter dropped +killed requirement pattern dropped +killed requirement filter dropped +Traceback (most recent call last): + File "/home/jwoltje/dewey-scratch/r89-out/mutants.py", line 57, in + r = subprocess.run(['node', '--test', *tests], cwd=M, env=env, capture_output=True, text=True, timeout=900) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/home/jwoltje/.pyenv/versions/3.12.8/lib/python3.12/subprocess.py", line 550, in run + stdout, stderr = process.communicate(input, timeout=timeout) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/home/jwoltje/.pyenv/versions/3.12.8/lib/python3.12/subprocess.py", line 1209, in communicate + stdout, stderr = self._communicate(input, endtime, timeout) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/home/jwoltje/.pyenv/versions/3.12.8/lib/python3.12/subprocess.py", line 2116, in _communicate + self._check_timeout(endtime, orig_timeout, stdout, stderr) + File "/home/jwoltje/.pyenv/versions/3.12.8/lib/python3.12/subprocess.py", line 1253, in _check_timeout + raise TimeoutExpired( +subprocess.TimeoutExpired: Command '['node', '--test', 'packages/bus/tests/*.test.mjs']' timed out after 900 seconds +end 2026-10-12T20:34:27Z diff --git a/agents/dewey/work/queue-89/mutants-run2.txt b/agents/dewey/work/queue-89/mutants-run2.txt new file mode 100644 index 000000000..0ec122e9c --- /dev/null +++ b/agents/dewey/work/queue-89/mutants-run2.txt @@ -0,0 +1,20 @@ +start 2026-10-12T20:35:08Z (run 2: the mutants from 'after ignored' on; run 1 hung there, mutants-run1.txt) +killed after ignored (hung: killed after 300 s) +killed after inclusive +killed total counts the page +killed description kept +killed tasks keys closed +killed ARG_DEFAULTS removed +killed default overrides the caller +killed cap at >= +SURVIVED cap counts bytes +killed indented JSON +killed cap 50000 +killed mcp server uncapped +killed pi uncapped +killed tool limit max 500 +killed get_task not required +killed get_task without tracker +killed bundle line pi names +killed bundle none dropped +end 2026-10-12T20:43:08Z diff --git a/agents/dewey/work/queue-89/mutants-run3.txt b/agents/dewey/work/queue-89/mutants-run3.txt new file mode 100644 index 000000000..ea94abe72 --- /dev/null +++ b/agents/dewey/work/queue-89/mutants-run3.txt @@ -0,0 +1,3 @@ +start 2026-10-12T20:43:36Z (run 3: the survivor, after the test added in tools.test.mjs) +killed cap counts bytes +end 2026-10-12T20:43:47Z diff --git a/agents/dewey/work/queue-89/mutants.py b/agents/dewey/work/queue-89/mutants.py new file mode 100644 index 000000000..986629375 --- /dev/null +++ b/agents/dewey/work/queue-89/mutants.py @@ -0,0 +1,69 @@ +# Row 89 mutants (Dewey): each swaps one exact string in the scratch copy of the candidate, runs +# the bus or harness node tests, restores the file, and reports killed (tests fail) or SURVIVED. +import subprocess, sys, os, signal +M = os.path.expanduser('~/dewey-scratch/r89mut') +env = dict(os.environ, TMPDIR=os.path.expanduser('~/dewey-scratch/tmp')) +B = 'packages/bus/src/broker.mjs' +T = 'packages/harness/src/tools.mjs' +MCP = 'packages/harness/src/mcp-server.mjs' +PI = 'packages/harness/src/pi-extension.mjs' +BU = 'packages/harness/src/bundle.mjs' +BUS = ['packages/bus/tests/*.test.mjs'] +HAR = ['packages/harness/tests/*.test.mjs'] +muts = [ + ('no args filtered too', B, " if (!Object.keys(a).length) return rows;\n", "", BUS), + ('tombstones kept', B, " .filter((r) => !r.fields.gone)\n", "", BUS), + ('next on an exact last page', B, "rest.length > limit ?", "rest.length >= limit ?", BUS), + ('default limit ignored', B, "const limit = a.limit ?? TASK_PAGE;", "const limit = a.limit ?? TASK_PAGE_MAX;", BUS), + ('limit max unchecked', B, "!positive(a.limit, TASK_PAGE_MAX)", "!positive(a.limit)", BUS), + ('assignee by raw id', B, ".filter((u) => bots.has(u)).map((u) => bots.get(u)).sort()", ".sort()", BUS), + ('assignees keep persons', B, ".filter((u) => bots.has(u)).map((u) => bots.get(u))", ".map((u) => bots.get(u) ?? u)", BUS), + ('task_ref with other args', B, "Object.keys(a).length !== 1 || ", "", BUS), + ('missing task_ref is null', B, "?? fail('task-not-found')", "?? null", BUS), + ('done check dropped', B, "('done' in a && typeof a.done !== 'boolean') ||", "", BUS), + ('done filter dropped', B, "(!('done' in a) || t.done === a.done) &&", "", BUS), + ('assignee any string', B, "!(typeof a.assignee === 'string' && Object.hasOwn(roles, a.assignee))", "typeof a.assignee !== 'string'", BUS), + ('label check dropped', B, "('label' in a && !positive(a.label)) ||", "", BUS), + ('label filter dropped', B, "(!('label' in a) || t.labels.includes(a.label)) &&", "", BUS), + ('requirement pattern dropped', B, " && /^REQ-[A-Z]+-[1-9][0-9]*$/.test(a.requirement)", "", BUS), + ('requirement filter dropped', B, "(!('requirement' in a) || t.requirement === a.requirement)", "true", BUS), + ('after ignored', B, "matched.filter((t) => t.task_ref > a.after)", "matched", BUS), + ('after inclusive', B, "t.task_ref > a.after", "t.task_ref >= a.after", BUS), + ('total counts the page', B, "total: matched.length", "total: tasks.length", BUS), + ('description kept', B, " title: f.title ?? null,\n", " title: f.title ?? null,\n description: f.description,\n", BUS), + ('tasks keys closed', B, "verb === 'tasks' ? TASK_ARGS : []", "[]", BUS), + ('ARG_DEFAULTS removed', T, "args: { ...ARG_DEFAULTS[tool], ...args }", "args", HAR), + ('default overrides the caller', T, "args: { ...ARG_DEFAULTS[tool], ...args }", "args: { ...args, ...ARG_DEFAULTS[tool] }", HAR), + ('cap at >=', T, "if (text.length > REPLY_CAP)", "if (text.length >= REPLY_CAP)", HAR), + ('cap counts bytes', T, "if (text.length > REPLY_CAP)", "if (Buffer.byteLength(text) > REPLY_CAP)", HAR), + ('indented JSON', T, "const text = JSON.stringify(result) ?? \"null\";", "const text = JSON.stringify(result, null, 2) ?? \"null\";", HAR), + ('cap 50000', T, "export const REPLY_CAP = 40_000;", "export const REPLY_CAP = 50_000;", HAR + BUS), + ('mcp server uncapped', MCP, "text: replyText(result)", "text: JSON.stringify(result)", HAR), + ('pi uncapped', PI, "text: replyText(result)", "text: JSON.stringify(result)", HAR), + ('tool limit max 500', T, "maximum: TASK_PAGE_MAX,", "maximum: 500,", HAR), + ('get_task not required', T, "obj({ task_ref: TASK_REF }, [\"task_ref\"])", "obj({ task_ref: TASK_REF })", HAR), + ('get_task without tracker', T, " add(\"get_task\", \"tasks\",", " }\n {\n add(\"get_task\", \"tasks\",", HAR), + ('bundle line pi names', BU, "(session.harness === \"pi\" ? resolved.limits.tools : claudeBuiltins({ tools: resolved.limits.tools }))", "resolved.limits.tools", HAR), + ('bundle none dropped', BU, ".join(\", \") || \"none\"}", ".join(\", \")}", HAR), +] +only = sys.argv[1:] +for name, f, old, new, tests in muts: + if only and not any(o in name for o in only): continue + p = os.path.join(M, f); src = open(p).read() + n = src.count(old) + if n != 1: print(f'BAD-ANCHOR ({n}) {name}', flush=True); continue + open(p, 'w').write(src.replace(old, new)) + # Its own process group, so a hung test's children die with it (a mutant that loops forever + # never fails on its own; a hang is a kill). + proc = subprocess.Popen(['node', '--test', *tests], cwd=M, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, start_new_session=True) + try: + out, _ = proc.communicate(timeout=300) + except subprocess.TimeoutExpired: + os.killpg(proc.pid, signal.SIGKILL); proc.communicate() + out = None + finally: + open(p, 'w').write(src) + if out is None: + print(f'killed {name} (hung: killed after 300 s)', flush=True); continue + fails = [l for l in out.splitlines() if l.startswith('not ok')] + print(('killed ' if proc.returncode else 'SURVIVED') + f' {name}' + (f' ({len(fails)} failing)' if fails else ''), flush=True) diff --git a/agents/dewey/work/queue-89/probe/probe-server.mjs b/agents/dewey/work/queue-89/probe/probe-server.mjs new file mode 100644 index 000000000..e05961d10 --- /dev/null +++ b/agents/dewey/work/queue-89/probe/probe-server.mjs @@ -0,0 +1,16 @@ +// Scratch MCP server for row 89: tool `big` returns a text block of exactly n characters. +import { createInterface } from "node:readline"; +const send = (m) => process.stdout.write(JSON.stringify({ jsonrpc: "2.0", ...m }) + "\n"); +createInterface({ input: process.stdin }).on("line", (line) => { + if (!line.trim()) return; + const m = JSON.parse(line); + if (m.method === "initialize") return send({ id: m.id, result: { protocolVersion: m.params?.protocolVersion ?? "2025-06-18", capabilities: { tools: {} }, serverInfo: { name: "probe", version: "1" } } }); + if (m.method === "ping") return send({ id: m.id, result: {} }); + if (m.method === "tools/list") return send({ id: m.id, result: { tools: [{ name: "big", description: "Return n characters", inputSchema: { type: "object", properties: { n: { type: "integer" } }, required: ["n"] } }] } }); + if (m.method === "tools/call") { + const n = m.params.arguments.n; + const unit = "abcdefghij"; + return send({ id: m.id, result: { content: [{ type: "text", text: unit.repeat(Math.ceil(n / 10)).slice(0, n) }] } }); + } + if (m.id !== undefined) send({ id: m.id, error: { code: -32601, message: "nf" } }); +}); diff --git a/agents/dewey/work/queue-89/probe/results.txt b/agents/dewey/work/queue-89/probe/results.txt new file mode 100644 index 000000000..e8305296a --- /dev/null +++ b/agents/dewey/work/queue-89/probe/results.txt @@ -0,0 +1,7 @@ +# Extract of the probe run (claude -p --output-format stream-json, MCP server probe-server.mjs). +# One line per tool call: the n asked for, the length of the text the model got back, its start. +claude_code_version 2.1.296 model claude-haiku-5-5 +n=49990 returned_length=49990 starts='abcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghij' +n=50000 returned_length=50000 starts='abcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghij' +n=50010 returned_length=1315 starts='Error: result (50,010 characters across 1 line) exceeds maximum allowed tokens. Output has been save' +total_cost_usd 0.01452308 is_error False