Files
stack/agents/rocko/work/queue-82/BUILD.md
T

8.4 KiB

Row 82 (#1551): runs list survives a damaged run record, candidate packet, round 2

Author: Rocko. Reviewer: Filbert. Brief: docs/plans/2026-10-12_runs-list-damaged-record.md, section "Runs list survives a damaged run record" (blob fb9da977, re-pinned at rev 606; first briefed as e9c7b769 at rev 601). Base: 876f1ffe (base.txt). The packet is uncommitted. I made no commits, pushes or Gitea calls and read no token or private binding.

Files (files.txt, candidate-manifest.sha256)

File Change
scripts/mosaic-task.mjs listRuns only: each of status, taskId, workspace and session is used only if it's a string
packages/runs/tests/task-cli.test.mjs +2 tests: a {} run between two good ones; one run per field with a value that isn't a string
packages/runs/README.md one row in the damaged-records table, and one clause in the paragraph above it
agents/rocko/work/queue-82/mutants.sh the mutant rerun: mutants.sh <tree>
agents/rocko/work/queue-82/byte-check.sh the base-against-candidate output check: byte-check.sh <base> <candidate> <out>

packages/runs/src/runs.mjs is unchanged.

Where the check sits: listRuns

listRuns is the right place, for three reasons:

  • listRuns is the only caller of listRunRecords (git grep listRunRecords). show goes through readRunRecord instead, so a fix in listRuns can't change show at all.
  • The package README says run records "are never validated beyond" being a JSON object, "because older records carry older shapes". runs.test.mjs:225 pins that: listRunRecords returns {} as {}. A field check in listRunRecords would have to replace the object with null (which loses the good fields of a half-damaged record) or rewrite fields. Either change is a contract change to the package, for a problem that only exists in one caller's formatting.
  • padEnd is the crash and listRuns is what calls it. The check sits next to the code that needs strings.

The change

if (result) {
  if (typeof result.status === "string") status = result.status;
  if (typeof result.taskId === "string") taskId = result.taskId;
  if (typeof result.workspace === "string") workspace = result.workspace;
  if (typeof result.session === "string") session = result.session;
}

The defaults stay unknown and -. String(workspace) in the print line became workspace, since workspace is now always a string.

Why a well-formed record prints the same: a run writes workspace and session from the task, so each is a string or null (scripts/mosaic-task.mjs:442-444). The base mapped null through ?? "-" to -, and the candidate's default is -. A string passes through unchanged in both. status and taskId are always strings in a well-formed record. Anything else crashed the base.

The only output change for a record that didn't crash is a workspace or session that is neither a string nor null. The base printed it through String (7, [object Object]) and the candidate prints -. That's the brief's rule, and the README row says so.

Tests (packages/runs/tests/task-cli.test.mjs)

  1. "list prints a damaged result.json as unknown and keeps listing the runs after it". The runs are a good A (ws1, s1), {}, and a good B (t-two, :run, null session). The good lines are in the same format the existing "established format" test pins.
  2. "list prints each result field that isn't a string as unknown or -". Four runs, each a good result with one field damaged: status: 5, taskId: null, workspace: 7 and session: {name: "s1"}.

Between them they cover the brief's {}, numeric status, null taskId, and a damaged run between two good ones. The second test gives each of the four checks its own case.

Runs suite: 43 pass on the candidate, against 41 on the base.

Mutants (out/mutants-candidate.txt, out/mutants-base.txt)

Each mutant drops one check and puts that field's base line back:

Mutant Line becomes Base tests Candidate tests
status-unchecked status = result.status; survived killed (tests 1 and 2)
taskid-unchecked taskId = result.taskId; survived killed (tests 1 and 2)
workspace-unchecked workspace = String(result.workspace ?? "-"); survived killed (test 2)
session-unchecked session = result.session ?? "-"; survived killed (test 2)

The base has no checks to drop, so "survives on the base" means this: the candidate's listRuns with the base's tests. I built that as a detached worktree at 876f1ffe with only the candidate scripts/mosaic-task.mjs copied in, and removed it afterwards. The workspace-unchecked mutant keeps the base's String(). Without it, 7.padEnd would crash, and the mutant would test the crash instead of the check.

The script saves the file, applies the sed and checks it changed something. It then runs packages/runs/tests/*.test.mjs and restores the file. It exits 1 on the base-tests tree, as it should when a mutant survives.

Byte check (out/byte-check.txt, out/byte-check/)

byte-check.sh runs base and candidate scripts/mosaic-task.mjs on the same data roots and compares stdout, stderr and exit status:

  • Seeded well-formed root: list and five shows are the same. It holds string and null workspace and session, a missing workspace and session, a task id over 18 characters, a run with only task.json, and r-.bad.
  • This host's real data root (~/.mosaic-dev, read-only, through my own config): list and all six shows are the same. The real records hold only strings and null in the four fields.
  • Seeded damaged root, recorded only: the base printed one line and crashed with TypeError: Cannot read properties of undefined (reading 'padEnd'), exit 1. The candidate printed all three lines, exit 0.

I removed the real-root output files from out/byte-check/ before packing, because they carry real runs' requests and responses. out/byte-check.txt keeps the result line for each.

Round 2: the paragraph above the table

Round 1 (candidate c8cbc227) never went to review. Its packet said the paragraph above the table claimed list and show print exactly what they printed before, apart from links, non-object documents and unreadable directories. That claim became false for a workspace or session that is neither a string nor null, and the paragraph was outside the first brief. Sage amended the brief in 09a8e0ee (blob fb9da977, rev 606). The README entry now owns the "How mosaic-task.mjs uses it" section, and the paragraph has to name the wrong-field-type case.

Round 2 adds one clause to the conditions: "no result.json with a field of the wrong type (a status or taskId that isn't a string, or a workspace or session that is neither a string nor null)". The wording matches the table row below it. Nothing else changed from round

  1. Sage ruled that a documentation-only change needs just the runs suite and byte-check.sh rerun, so I reran both on the round 2 tree (out/r2/). Runs suite: 43 pass, 0 fail. byte-check.sh: exit 0, every seeded and real comparison the same, and the damaged list unchanged (base rc 1 and 1 line, candidate rc 0 and 3 lines). As in round 1, I left the real-root output files out of the packet. The gate below is round 1's, on code identical to round 2's.

Gate, round 1 (out/gate/, sequential, scratch worktree at 876f1ffe with the candidate, node_modules linked)

2026-10-12T01:31:06Z to 01:37:34Z. Every suite exited 0. out/gate.sh is the script, out/gate/summary.txt the summary, and out/gate/*.log the full logs.

Suite Result
packages/runs 43 pass, 0 fail
packages/tasks 71 pass, 0 fail
packages/bus 94 pass, 0 fail
packages/business 60 pass, 0 fail
packages/harness 59 pass, 0 fail
packages/cli 114 pass, 0 fail
packages/webui 62 pass, 0 fail
packages/seat 27 pass, 0 fail
test-auth.sh 15 passed, 0 failed
test-conductor.sh 17 passed, 0 failed
test-config.sh 24 passed, 0 failed
test-discord.sh 66 passed, 0 failed
test-extension-package.sh 18 passed, 0 failed
test-foundation.sh 44 passed, 0 failed
test-queue.sh 27 passed, 0 failed
test-release.sh 14 passed, 0 failed
test-task.sh 98 passed, 0 failed

Those are all nine scripts/test-*.sh at the base. Afterwards I removed the rocko-r82_default network.