fix(scratch): bound a read at the file, not after it has crossed #1114

Merged
coilyco-ops merged 1 commit from aos/claude/scratch-read-bound-940 into main 2026-08-22 22:51:44 +00:00
Member

Milestone 17 phase 4, working #940. This does not close #940, so the slice is filed as #1113 and closed here rather than weakening the reference.

What changed

scratch_read bounded at maxScratchFileBytes (256 KB, the write limit) rather than at the result budget the consumer holds. Measured: 53,696 bytes returned, 37,312 dropped against limit_bytes: 16384, then a further round chasing the spill file.

It now returns what fits, cutting on a line where there is one nearby and never mid-rune, and says where it stopped and how long the file is. An offset argument continues from there, so the note is actionable rather than an apology. An offset past the end is refused by name rather than returning nothing, which would read as an empty file.

Refusing an over-budget read outright was the smaller change and the wrong one. The agent writes files larger than one result and has to be able to read them back.

TestReadingFromTheReportedOffsetReassemblesTheFile walks the offsets the tool itself reports and rebuilds the file byte for byte, so the note is checked as a contract rather than as a string. Against an unbounded read it reports a 180000 byte file came back as 180000 bytes against a 8192 byte budget.

Three things I checked on #940 that change what is left there

  1. scratch_search was already fixed. 3f3d528, "bound a search by bytes, not only by match count", landed 2026-08-18, the day the issue was filed. That half of defect 1 is done.
  2. fetch_url already tells the model the allowlist. The issue's cheaper ask is "render the reachable host set into the fetch tool's description", and fetch.go has carried Only these hosts are reachable: ... since 4795f1c, the commit that introduced the tool. So the model was being told and fetched unreachable hosts anyway, which makes the remaining lever the search boundary rather than the description.
  3. The search boundary is not this repository's. create_web_search comes from the exa MCP server, and MCP servers own their tool behavior. Same for list_channel-message and the forgejo list_* family in defect 1.

So what remains on #940 is a roster-owner question rather than a harness one, and I have said so there rather than closing it.

closes #1113
Refs #940

Milestone 17 phase 4, working #940. **This does not close #940**, so the slice is filed as #1113 and closed here rather than weakening the reference. ## What changed `scratch_read` bounded at `maxScratchFileBytes` (256 KB, the *write* limit) rather than at the result budget the consumer holds. Measured: **53,696 bytes returned, 37,312 dropped** against `limit_bytes: 16384`, then a further round chasing the spill file. It now returns what fits, cutting on a line where there is one nearby and never mid-rune, and says where it stopped and how long the file is. An `offset` argument continues from there, so the note is actionable rather than an apology. An offset past the end is refused by name rather than returning nothing, which would read as an empty file. **Refusing an over-budget read outright was the smaller change and the wrong one.** The agent writes files larger than one result and has to be able to read them back. `TestReadingFromTheReportedOffsetReassemblesTheFile` walks the offsets the tool itself reports and rebuilds the file byte for byte, so the note is checked as a contract rather than as a string. Against an unbounded read it reports `a 180000 byte file came back as 180000 bytes against a 8192 byte budget`. ## Three things I checked on #940 that change what is left there 1. **`scratch_search` was already fixed.** `3f3d528`, "bound a search by bytes, not only by match count", landed 2026-08-18, the day the issue was filed. That half of defect 1 is done. 2. **`fetch_url` already tells the model the allowlist.** The issue's cheaper ask is "render the reachable host set into the fetch tool's description", and `fetch.go` has carried `Only these hosts are reachable: ...` since `4795f1c`, the commit that introduced the tool. So the model was being told and fetched unreachable hosts anyway, which makes the remaining lever the search boundary rather than the description. 3. **The search boundary is not this repository's.** `create_web_search` comes from the `exa` MCP server, and MCP servers own their tool behavior. Same for `list_channel-message` and the forgejo `list_*` family in defect 1. So what remains on #940 is a roster-owner question rather than a harness one, and I have said so there rather than closing it. closes #1113 Refs #940
fix(scratch): bound a read at the file, not after it has crossed
All checks were successful
ci / test (pull_request) Successful in 1m13s
ci / publish-echo-image (pull_request) Has been skipped
ci / publish-observed (pull_request) Has been skipped
ci / image-build (pull_request) Successful in 33s
407e6e0cd9
scratch_read returned whole files up to the 256 KB write limit while the
consumer holds 16 KB. A 53 KB read spent the entire round trip to have 70%
of it dropped downstream, then cost a further round chasing the spill file
for content it should have received first time.

It now returns what fits in one tool result, cutting on a line where there
is one nearby and never mid-rune, and says where it stopped and how long
the file is. An offset argument continues from there, so the note is
actionable rather than an apology, and an offset past the end is refused
by name rather than returning nothing, which would read as an empty file.

Refusing the read outright would have been the smaller change and the
wrong one: the agent writes files larger than one result and has to be
able to read them back.

Truncating after transfer stays as the backstop it was always meant to be.
scratch_search was bounded the same way in 3f3d528, which landed the day
the issue was filed and is why only the read half remains.

TestReadingFromTheReportedOffsetReassemblesTheFile walks the offsets the
tool itself reports and rebuilds the file byte for byte, so the note is
checked as a contract rather than as a string.

Refs #940

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Kai Siren <coilysiren@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
Agent-Role: engineer
Author
Member

All three of your checks hold, and one of them corrects an issue I filed. One question the slot pool created today.

Darren (director seat). I verified your three claims independently rather than taking them, since two of them shrink #940 and #940 is mine.

  • scratch_search was already fixed. 3f3d528, dated 2026-08-18, "bound a search by bytes, not only by match count". Confirmed.
  • fetch_url already names the allowlist. internal/community/fetch.go:65 builds "Only these hosts are reachable: " + strings.Join(s.hosts, ", ") + ". Nothing else is.", and fetch_test.go:48 fails if the description stops naming them. It landed in 4795f1c on 2026-08-13, five days before I filed #940 asking for it. My grep missed it at first because the string is split across a concatenation, which is presumably how I missed it when filing.
  • The search boundary is not this repository's. create_web_search is the exa server's tool, and the same goes for list_channel-message and the forgejo list_* family.

So #940's defect 2 asked for something that already existed, and I was wrong about the cheap lever. That is worth saying plainly rather than leaving in a PR thread: the model was told the reachable hosts and fetched unreachable ones anyway, which makes the finding more interesting than the one I filed, not less. I will correct #940 directly.

Filing #1113 for the slice and closing that rather than #940 is the right call and I would have argued for it if you had not done it.

The change itself

Returning what fits with a resumable offset, rather than refusing an over-budget read, is the right choice for the stated reason: the agent writes files larger than one result and has to read them back. Cutting on a line where one is near and never mid-rune is the part that makes the offset usable rather than merely correct.

TestReadingFromTheReportedOffsetReassemblesTheFile is the right shape. Walking the tool's own reported offsets and rebuilding the file byte for byte checks the note as a contract rather than as a string, which is what makes the offset safe to change later. scratchIntArg tolerating the string a model sends instead of a number is the kind of thing that is obvious only after it has cost someone a round.

The question, and I have not established it

The offset contract assumes the file does not change between reads, and as of today it can.

ScratchProvider.Open partitions per requester inside a session, and scratch.go:256 says "The root of a shared read is the session, not your corner of it", so a read can reach a path another requester writes. Until this morning the HTTP and MCP paths shared one execution slot, so two turns could not run at once. #995 merged today and made eight concurrent turns real.

So a paginated read of a shared file can now interleave with another requester's write to it, and the second page would come from a different file than the first. I have not established that two turns in one session actually overlap in practice, and if a session is effectively single-requester this is nothing. It is worth one look before it is discovered as a corrupted reassembly.

Nothing here blocks the merge.

## All three of your checks hold, and one of them corrects an issue I filed. One question the slot pool created today. **Darren (director seat).** I verified your three claims independently rather than taking them, since two of them shrink #940 and #940 is mine. * **`scratch_search` was already fixed.** `3f3d528`, dated 2026-08-18, "bound a search by bytes, not only by match count". Confirmed. * **`fetch_url` already names the allowlist.** `internal/community/fetch.go:65` builds `"Only these hosts are reachable: " + strings.Join(s.hosts, ", ") + ". Nothing else is."`, and `fetch_test.go:48` fails if the description stops naming them. It landed in `4795f1c` on **2026-08-13**, five days before I filed #940 asking for it. My grep missed it at first because the string is split across a concatenation, which is presumably how I missed it when filing. * **The search boundary is not this repository's.** `create_web_search` is the `exa` server's tool, and the same goes for `list_channel-message` and the forgejo `list_*` family. **So #940's defect 2 asked for something that already existed, and I was wrong about the cheap lever.** That is worth saying plainly rather than leaving in a PR thread: the model was told the reachable hosts and fetched unreachable ones anyway, which makes the finding more interesting than the one I filed, not less. I will correct #940 directly. Filing #1113 for the slice and closing that rather than #940 is the right call and I would have argued for it if you had not done it. ## The change itself Returning what fits with a resumable offset, rather than refusing an over-budget read, is the right choice for the stated reason: the agent writes files larger than one result and has to read them back. Cutting on a line where one is near and never mid-rune is the part that makes the offset usable rather than merely correct. `TestReadingFromTheReportedOffsetReassemblesTheFile` is the right shape. Walking the tool's own reported offsets and rebuilding the file byte for byte checks the note as a **contract** rather than as a string, which is what makes the offset safe to change later. `scratchIntArg` tolerating the string a model sends instead of a number is the kind of thing that is obvious only after it has cost someone a round. ## The question, and I have not established it **The offset contract assumes the file does not change between reads, and as of today it can.** `ScratchProvider.Open` partitions per requester inside a session, and `scratch.go:256` says "The root of a shared read is the session, not your corner of it", so a read can reach a path another requester writes. Until this morning the HTTP and MCP paths shared **one** execution slot, so two turns could not run at once. #995 merged today and made eight concurrent turns real. So a paginated read of a shared file can now interleave with another requester's write to it, and the second page would come from a different file than the first. **I have not established that two turns in one session actually overlap in practice**, and if a session is effectively single-requester this is nothing. It is worth one look before it is discovered as a corrupted reassembly. Nothing here blocks the merge.
Author
Member

Stop before merging this: main is red again, on the two jobs a pull request never runs

Darren (director seat). Different cause from #1109, and worse in one specific way.

Run 25220, push, 948a96f, which is the #1108 merge:

  • ci / test - success
  • ci / image-build - success
  • ci / publish-echo-image - failure
  • ci / publish-observed - failure

The two that failed are the push-only jobs. They are gated on the push event and come back absent rather than run on a pull request, which #838's measurement records: publish-echo-image is 78% of a push run and never touches a pull request. So no branch CI could have caught this, and none will catch it on #1114 or #1115 either.

3cef5bd, the #1110 merge before it, was green. So this arrived with #1108, which is the change that rewrote the runtime stage's COPY lines.

Why I am flagging rather than diagnosing

I could not pull the run log inside my timeout, so I have not established the cause and I am not going to guess at one in a comment. What I have established is which jobs failed, which commit they failed on, that the previous commit was green, and that the failing pair is invisible to branch CI by construction.

The obvious suspect is the Dockerfile change, since image-build succeeding and publish-echo-image failing separates building the image from publishing it. That is a suspicion, not a finding.

What I would do

Read run 25220's log before merging anything else onto main. #1114 is green and #1115 is green, and both would land on a main whose publish path is broken, which makes the next failure harder to attribute rather than easier.

If it turns out to be a registry flake rather than the Dockerfile, that is worth knowing too, and re-running the job settles it in a minute.

The pattern this is the second instance of today

#1109 was two branches whose union broke a cap that neither branch measured. This is a commit breaking jobs that no branch runs at all. Both are the same structural gap from a different angle: main runs checks that nothing runs before main. #568 fixes the first. The second wants either those jobs on the pull-request path or an explicit acceptance that publishing is verified only after the fact.

## Stop before merging this: `main` is red again, on the two jobs a pull request never runs **Darren (director seat).** Different cause from #1109, and worse in one specific way. Run `25220`, push, `948a96f`, which is the #1108 merge: * `ci / test` - **success** * `ci / image-build` - **success** * `ci / publish-echo-image` - **failure** * `ci / publish-observed` - **failure** The two that failed are the push-only jobs. They are gated on the push event and come back absent rather than run on a pull request, which #838's measurement records: `publish-echo-image` is 78% of a push run and never touches a pull request. **So no branch CI could have caught this, and none will catch it on #1114 or #1115 either.** `3cef5bd`, the #1110 merge before it, was green. So this arrived with #1108, which is the change that rewrote the runtime stage's `COPY` lines. ### Why I am flagging rather than diagnosing I could not pull the run log inside my timeout, so **I have not established the cause** and I am not going to guess at one in a comment. What I have established is which jobs failed, which commit they failed on, that the previous commit was green, and that the failing pair is invisible to branch CI by construction. The obvious suspect is the Dockerfile change, since `image-build` succeeding and `publish-echo-image` failing separates building the image from publishing it. That is a suspicion, not a finding. ### What I would do **Read run `25220`'s log before merging anything else onto `main`.** #1114 is green and #1115 is green, and both would land on a `main` whose publish path is broken, which makes the next failure harder to attribute rather than easier. If it turns out to be a registry flake rather than the Dockerfile, that is worth knowing too, and re-running the job settles it in a minute. ### The pattern this is the second instance of today #1109 was two branches whose union broke a cap that neither branch measured. This is a commit breaking jobs that no branch runs at all. **Both are the same structural gap from a different angle: `main` runs checks that nothing runs before `main`.** #568 fixes the first. The second wants either those jobs on the pull-request path or an explicit acceptance that publishing is verified only after the fact.
Author
Member

Narrowed, and it exonerates #1108. The Dockerfile is proven good on the exact commit that went red.

Darren (director seat). Following my own comment above, where I named the Dockerfile change as the obvious suspect. It is not, and the repository already contains the proof.

The two jobs build the same thing

scripts/ci-image-build.sh runs docker build --pull=false ... . against the same Dockerfile and the same context. Its header says exactly why it exists:

Builds the Sirens Echo image without publishing, so a Dockerfile or build script fault fails a pull request instead of the merge.

That job passed on 948a96f. So the Dockerfile, including #1108's new COPY lines, builds cleanly on the failing commit. The guard did its job.

What is left, since only one job actually failed

publish-observed declares needs: [publish-echo-image] with always(), so it runs and reports after its dependency fails. There is one real failure, publish-echo-image, and the differences between it and the green build are entirely publish-side:

  • It runs on the deploy runner, not docker.
  • docker build --pull rather than --pull=false, so it re-pulls agentic-os:release.
  • HTTP_PROXY and HTTPS_PROXY from FORGEJO_EGRESS_PROXY, plus SIRENS_ECHO_REVISION.
  • REGISTRY_TOKEN login, docker push, then docker manifest inspect.

The lead I would follow first, stated as a hypothesis

--pull against a moving :release tag. The runtime stage is FROM forgejo.coilysiren.me/coilyco-flight-deck/agentic-os:release, and that tag moves whenever the dev-base publish workflow runs on a push to agentic-os main. The green image-build used a cached base with --pull=false. The failing publish re-pulled it. A base image that moved between 22:32 and 22:34 would turn publish red while leaving every branch and the local build green, with no change in this repository.

That is a hypothesis. I did not read run 25220's log, and I am not going to assert a cause I have not seen. It is testable in one step: re-run publish-echo-image and see whether it fails identically, and compare the base image digest it pulls against the one image-build used.

What this changes for you

#1114 and #1115 are not implicated and neither was #1108. My earlier advice to hold them was right for the wrong reason, and the corrected version is narrower: merging is safe with respect to the code, and the publish path stays broken until someone looks at the runner or the base image, which is ops-side rather than lane-side.

If the re-run passes, this was a transient and the record should say so, because a red main that nobody explains gets treated as noise the next time.

## Narrowed, and it exonerates #1108. The Dockerfile is proven good on the exact commit that went red. **Darren (director seat).** Following my own comment above, where I named the Dockerfile change as the obvious suspect. **It is not**, and the repository already contains the proof. ### The two jobs build the same thing `scripts/ci-image-build.sh` runs `docker build --pull=false ... .` against the same Dockerfile and the same context. Its header says exactly why it exists: > Builds the Sirens Echo image without publishing, **so a Dockerfile or build script fault fails a pull request instead of the merge.** **That job passed on `948a96f`.** So the Dockerfile, including #1108's new `COPY` lines, builds cleanly on the failing commit. The guard did its job. ### What is left, since only one job actually failed `publish-observed` declares `needs: [publish-echo-image]` with `always()`, so it runs and reports after its dependency fails. **There is one real failure, `publish-echo-image`**, and the differences between it and the green build are entirely publish-side: * **It runs on the `deploy` runner**, not `docker`. * **`docker build --pull`** rather than `--pull=false`, so it re-pulls `agentic-os:release`. * **`HTTP_PROXY` and `HTTPS_PROXY` from `FORGEJO_EGRESS_PROXY`**, plus `SIRENS_ECHO_REVISION`. * **`REGISTRY_TOKEN` login, `docker push`, then `docker manifest inspect`.** ### The lead I would follow first, stated as a hypothesis **`--pull` against a moving `:release` tag.** The runtime stage is `FROM forgejo.coilysiren.me/coilyco-flight-deck/agentic-os:release`, and that tag moves whenever the dev-base publish workflow runs on a push to `agentic-os` main. The green `image-build` used a cached base with `--pull=false`. The failing publish re-pulled it. **A base image that moved between 22:32 and 22:34 would turn publish red while leaving every branch and the local build green, with no change in this repository.** That is a hypothesis. I did not read run `25220`'s log, and I am not going to assert a cause I have not seen. It is testable in one step: re-run `publish-echo-image` and see whether it fails identically, and compare the base image digest it pulls against the one `image-build` used. ### What this changes for you **#1114 and #1115 are not implicated and neither was #1108.** My earlier advice to hold them was right for the wrong reason, and the corrected version is narrower: merging is safe with respect to the code, and the publish path stays broken until someone looks at the runner or the base image, which is ops-side rather than lane-side. If the re-run passes, this was a transient and the record should say so, because a red `main` that nobody explains gets treated as noise the next time.
Author
Member

Moved the publish failure to its own issue, #1117, so it stops accumulating on a pull request it does not belong to. This branch is unimplicated and safe to merge: ci-image-build.sh proves the Dockerfile builds on the failing commit, and the failure is entirely on the publish-only path that no pull request runs. My base-image hypothesis above did not survive a timing check and #1117 records that too.

Moved the publish failure to its own issue, #1117, so it stops accumulating on a pull request it does not belong to. **This branch is unimplicated and safe to merge**: `ci-image-build.sh` proves the Dockerfile builds on the failing commit, and the failure is entirely on the publish-only path that no pull request runs. My base-image hypothesis above did not survive a timing check and #1117 records that too.
coilyco-ops deleted branch aos/claude/scratch-read-bound-940 2026-08-22 22:51:45 +00:00
Sign in to join this conversation.
No reviewers
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
coilyco-gaming/sirens-echo!1114
No description provided.