Skip to content

fix(fleet-write): read every relayed body back — a stored body that is not the bytes sent exits 4, never 0 - #20806

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-20571-fleet-write-byte-exact
Sep 30, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-20571-fleet-write-byte-exact

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #20571
Clause-②: no

What this changes

scripts/pm/fleet-write/dispatch.mjs only. sendFleetWrite is the one send path every relay door shares (dispatch.mjs --actions-file, with-fleet.sh --via dispatch, post-stamped, issue-create, label-write, issue-transfer, close-cards). After a run concludes success, it now reads back every action that carried a body and compares the stored bytes with the bytes sent. Until now a success run showed only that the executor's requests got 2xx answers. It never showed that the board holds what the seat sent, and the card's 41,699-byte issue_patch exited 0 twice with U+FFFD stored in it.

  • The judge is post-stamped's own classifyReadBack / sentBodyLanded, imported. There is no second copy. The declared normalisations (the platform footer block, a stripped trailing newline) are exactly the ones post-stamped already forgives. The import is dynamic because post-stamped imports this file statically, and a static import back would create a cycle. One cell is added, and it applies to pr_create only. The platform appends a rule plus one session-URL footer after every byte sent (the fourth shape in platform-readings.md, where the sent body is a strict prefix of the stored one). The cell forgives that append and nothing inside the bytes sent.
  • Targets. BODY_OPS is derived from ops.mjs: comment, comment_edit, issue_patch, issue_create and pr_create. READ_BACK_LOCATORS must name exactly that set, and this is pinned, so a new body op gets read back as soon as it lands.
  • Verdicts.
    • landed: success, unchanged.
    • not-stored: the object the action wrote was read, and a byte sent is not the byte stored at that offset. Result: state: failure with notStored: true, exit 4 (EXIT_NOT_STORED). The output names the op, the object and the first differing byte, and counts extra U+FFFD.
    • unverified: the object could not be read, or a new comment, issue or pull could not be found. Result: state: unverified, exit 6. A comment can only be found by its content, so a comment that matches nothing is always unverified, never not-stored.
    • None of these is ever retried.
  • Entry. The CLI entry no longer awaits main at top level. The read-back imports post-stamped, which imports this file, so a pending top-level await is a deadlock. Measured: exit 13, "unsettled top-level await" (ablation B below).
  • main(argv, deps) takes a test-only deps so the self-test can drive the card's stroke through the real CLI exit code. --json gains not_stored and per-action read_back rows.

How each caller reads the new outcomes (one exit vocabulary, no caller edited)

  • failure + notStored keeps the state every caller already reads as "not kept whole: go READ, never fall back".
    • post-stamped's relayExitFor maps failure to its EXIT_NOT_STORED (4).
    • exitForResult now returns 4 for it. That covers issue-create (its EXIT_READ_BACK_MISMATCH is 4), label-write, issue-transfer and close-cards.
  • unverified is a state no caller names, so each one falls to its non-success branch: exitForResult gives 6, and post-stamped's relayExitFor gives its default 6. It is never a fall-back to direct (only no-run is) and never 0.
  • A stroke with no body (labels, assignees, state, transfer, the GraphQL ops) reads nothing back and returns before the judge is even loaded. Its outcome is unchanged.

The cause: measured and not reproduced in the relay path

The triage asked for measurement of where the bytes are split. Every leg this repository executes, and the live relay end to end from this container, came out byte-exact:

  1. Offline, the relay's own code (pinned by the new round-trip battery). A body of exactly 41,699 bytes, with a multi-byte character across every 16 KiB boundary of four streams: the body itself, the dispatch wire, the runner's env text (the workflow's toJSON, modelled as 2-space JSON), and the executor's own PATCH request. It goes through pack, wire, the platform's parse, FLEET_WRITE_PAYLOAD into validate.mjs --from-env in its own process, and executeFleetWrite's captured request. The result is byte-for-byte identical. The same wire decoded one 16 KiB chunk at a time (the defect the card suspects) reads back NOT STORED at exactly the body byte the first wire boundary split.
  2. This container's egress, response direction. Three Chinese-dense files were read raw through the proxy at 7a09eee1 and compared with the local blobs: platform-readings.md 47,980 B, os-dev.md 37,191 B, pm-dispatch SKILL.md 29,167 B. All three are byte-identical.
  3. Live, the one measurement write this card allowed. It was a comment on finding(fleet-write): an issue_patch body of about 41 KB comes back with two multi-byte characters replaced by U+FFFD, about 16 KB apart, identically on a re-send — and dispatch.mjs reports success without comparing the read-back #20571 made with this branch's dispatch.mjs, using the same straddling construction (41,699 bytes, every 16 KiB boundary of body, wire, env text and executor request inside a character). Request fw-20260930T070826Z-391d62, run 36682018227, comment 5906059721. The read-back reported IDENTICAL: 41,699 bytes sent, 41,699 stored. An independent curl read agreed: byte-identical, 0 U+FFFD, authored by objectstack-fleet[bot].

So nothing in the path this repository executes, and nothing in the live relay from this container, splits a character. Not measured: the filing seat's own container egress; whatever produced that seat's actions file (dispatch.mjs reads the file whole); and an issue_patch on a pull request specifically (the probe was a comment). The read-back makes any split downstream of the actions file loud (exit 4, with the offset). A split before the file cannot be seen from here, because the file is the only "sent" this tool holds. That question is in the report, not in this diff.

Verification

Every reading below was taken at this PR's head 4a8dafbb.

Gates: the 36 families dispatch-gates --repo objectstack-ai/objectstack --commands derives from this diff (it matches the 36 the dispatch listed), plus check:pm-issue-transfer and check:pm-with-fleet. Those two are the self-tests of the other files that name fleet-write/dispatch (os-dev rule 5). Every one exited 0 at 4a8dafbb. dispatch-gates --ran over the exit-annotated record reports: 36 derived, 36 run, 0 NOT-MEASURED, 0 UNRUN, a derived zero.

node scripts/check-ci-filter-parity.mjs : exit 0
node scripts/check-closing-keyword-parity.mjs : exit 0
node scripts/check-closing-keyword-parity.mjs --self-test : exit 0
node scripts/check-comment-mask-corpus.mjs : exit 0
node scripts/check-declaration-mirrors.mjs : exit 0
node scripts/check-declaration-mirrors.mjs --self-test : exit 0
node scripts/check-scripts-symbol-anchors.mjs : exit 0
node scripts/check-scripts-symbol-anchors.mjs --self-test : exit 0
node scripts/check-self-test-wired.mjs : exit 0
node scripts/check-self-test-wired.mjs --self-test : exit 0
node scripts/check-self-test-workflow-commands.mjs : exit 0
node scripts/check-self-test-workflow-commands.mjs --self-test : exit 0
node scripts/check-whole-set-label-write.mjs : exit 0
node scripts/check-whole-set-label-write.mjs --self-test : exit 0
node scripts/pm/bare-root-worklist.mjs --self-test : exit 0
pnpm check:agent-test-spelling : exit 0
pnpm check:bash32-floor : exit 0
pnpm check:cli-command-ids : exit 0
pnpm check:cross-package-test-inputs : exit 0
pnpm check:driver-memory-census : exit 0
pnpm check:entry-guard : exit 0
pnpm check:gitlink-declared : exit 0
pnpm check:nul-bytes : exit 0
pnpm check:parse-guard : exit 0
pnpm check:pm-close-cards : exit 0
pnpm check:pm-dispatch-gates : exit 0
pnpm check:pm-fleet-write-dispatch : exit 0
pnpm check:pm-fleet-write-execute : exit 0
pnpm check:pm-fleet-write-validate : exit 0
pnpm check:pm-issue-create : exit 0
pnpm check:pm-label-write : exit 0
pnpm check:pm-post-stamped : exit 0
pnpm check:pm-write-pace : exit 0
pnpm check:pnpm-filter-targets : exit 0
pnpm check:refd-timer-probe : exit 0
pnpm check:watch-hint-literal : exit 0
pnpm check:pm-issue-transfer : exit 0
pnpm check:pm-with-fleet : exit 0

The self-test verdict line: fleet-write/dispatch self-test: 146 cases pass across 15 batteries. The four new batteries are pinned by name with floors: targets 6, verdict 13, round trip 8, end to end 17. The wiring battery went from 4 to 5 (the entry pin), and the battery floor from 11 to 15.

Ablations

Each ablation ran against the committed implementation through scripts/ablation-replace.mjs with an EXIT/INT/TERM restore trap. Each restore was proven by the tool (blob == HEAD, git diff HEAD empty).

  • A: the read-back skipped (readBackStroke replaced with { state: 'none' }). The self-test goes RED, 14 of 146. The card's stroke with a corrupted read-back comes back through the CLI as [0, false, null], which is exit 0 and exactly the card's defect. With the read-back in place it is exit 4.
  • B: top-level await restored on the entry. The self-test never reaches its verdict. Node exits 13 with "Detected unsettled top-level await": the deadlock the entry change exists to prevent.

Acceptance notes

These were noted but not filed. None of them is a reproducible defect with a public door.

  • Layering. dispatch.mjs borrows its judge from post-stamped through a dynamic import, which is safe only while no entry in post-stamped's static import graph awaits at top level while sending a body. Today that is only dispatch.mjs (fixed and pinned here); label-write sends no body. Moving the classifier into a leaf module that both files import would remove the cycle. No PR touching post-stamped's read-back core is currently open to carry it.
  • Wording. A not-stored or unverified outcome reaching post-stamped or issue-create prints their existing "relay run FAILED" prefix before this file's detail. The exit codes are correct; the prefix is theirs.
  • Scope of the comparison. Titles are not read back. The platform's title normalisation is unmeasured, and a strict comparison could report a correct write as failed.
  • post-stamped's first-difference window removes U+FFFD at its edges, so the read-back line counts the extra replacement characters itself.

Generated by Claude Code

…dy that is not the bytes sent

A run concluding success proved the executor's requests were answered,
never that the board holds the bytes the seat sent. sendFleetWrite now
reads back every action that carried a body and judges the stored bytes
with post-stamped's own classifyReadBack: a measured mismatch is failure
with notStored (exit 4, op, object and first differing byte named), a
body no locator can read or find is unverified (exit 6), never success.

Claude-Session: https://claude.ai/code/session_01KTZmMfzVzjNvyaLyQ8mHvg
Co-authored-by: Claude <noreply@anthropic.com>
…1,699-byte round trip and exit 4 through the CLI

Claude-Session: https://claude.ai/code/session_01KTZmMfzVzjNvyaLyQ8mHvg
Co-authored-by: Claude <noreply@anthropic.com>
…en the read-back is gone

Claude-Session: https://claude.ai/code/session_01KTZmMfzVzjNvyaLyQ8mHvg
Co-authored-by: Claude <noreply@anthropic.com>
…er the raw glyph

Claude-Session: https://claude.ai/code/session_01KTZmMfzVzjNvyaLyQ8mHvg
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet objectstack-fleet Bot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Sep 30, 2026
@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 30, 2026 09:00
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 30, 2026
Merged via the queue into main with commit be47d0c Sep 30, 2026
39 of 40 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20571-fleet-write-byte-exact branch September 30, 2026 09:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/l skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

2 participants