Skip to content

feat(harness): run the contract save's file checks in a child with a deadline (TH-9445) - #310

Open
khushalsonawat wants to merge 12 commits into
feat/TH-8804-source-subfolderfrom
feat/TH-9445-save-checks-child
Open

khushalsonawat wants to merge 12 commits into
feat/TH-8804-source-subfolderfrom
feat/TH-9445-save-checks-child

Conversation

@khushalsonawat

@khushalsonawat khushalsonawat commented Nov 12, 2026 •

Copy link
Copy Markdown
Member

Review only 21fcddc, the merge 6cf081b and b4f4122. Everything before them is the base: feat/TH-8804-source-subfolder at c0e51b7 (#225, with #268, #276 and #283 merged in) and #281's head e174292, which is still open.

TH-9445.

Summary

When the builder saves the agent's contract, the kit reads the customer's files: the browser-tool scan and the import walk, tool evidence, the prompt copy, the model citation, the sibling-build check, and instrumentation. That reading ran in a thread. Python's parser holds the GIL, so a crafted repository could hold the conversation for seconds, and every TH-8804 review round found a new shape just past one of the roughly 28 limits that keep it short.

This change runs those checks in a forkserver child, one per builder session, with one wall-clock deadline per save:

  • The deadline is 1.5 s per call, from when the call starts. It covers everything the call waits on: the child's start-up, the session job, another call's turn, and its own checks.
  • A save the deadline cuts saves nothing. It answers with the problems that arrived, or "not checked in time", and the model writes the contract again.
  • The limits stay as they are. They still decide today's answers and bound memory. The deadline only stops what they don't.
  • The session job runs once per session, as soon as the child is up: the browser scan and instrumentation, which depend only on the session. It has its own 30 s bound, and no save's deadline kills it.
  • The save job runs once per save: today's _checked_contract, unchanged in what it checks.
  • If the child can't start (no forkserver, a failed preload or trial), or dies twice in a row, the session falls back to today's in-thread checks, with a WARNING.
  • A memory watchdog kills the child at about 400 MB.
  • Each tool server serves one event loop. A call on another loop runs today's in-thread checks.

Changes

  • save_checker.py (new): SaveChecker, the parent side. It handles the deadline, the cache, the reader and watchdog threads, kill and restart, the death count and the thread fallback.
  • save_child.py (new) and forkserver_preload.py (new): the child, its trial, and the shared preload list. scan_folders.py uses the same list.
  • tools.py: submit_contract asks the checker. contract_tools builds one checker per tool server, starts it only when there is a source to read, and closes it with a finalizer on the handler, which survives the hosted chat's copy of the server.
  • _checked_contract gains two keywords, instrumentation= and progress=. Nothing else in it changes.
  • Tests: test_save_child.py and test_save_checker.py (new, both added to sdk-smoke.yml), plus child-mode rows in the contract-tool tests. A conftest fixture closes every checker after each test and fails a test that leaves a child alive. The 15 tests that patch something the child runs, and test_agent_reading.py's two, run in thread mode.

Testing

  • CI: sdk-smoke runs test_save_child.py and test_save_checker.py, which this PR adds to its list, along with every other test file it touches.

  • The CI list, run on a Mac with CI's install (3.12.14): 4,884 passed, 3 skipped, 2 deselected, 1 xfailed.

    • Skipped: test_certify_only.py's real seed test (3 cases). It needs ALK_TEST_POSTGRES_DSN.
    • Deselected, as in CI: test_private_github_submission_becomes_hosted_job and test_public_github_branch_url_populates_job_ref. They fail on dev too (github_commit_sha_required, TH-8978).
    • Expected failure: test_hosted_github_route_accepts_a_submitted_value. Submit doesn't yet give hosted jobs the dedicated_vm runtime.
  • Changed-area files on 3.12.3, on a Mac: 2,714 passed, after merging fix(harness): give a browser scan its limits cut a second look (TH-9512) #281's head.

  • Planted bugs: 509 planted, 491 caught. The rest are equivalent mutants or the known limits below. The two new gaps the plants found now have rows that fail on them.

  • Hosted gate: 30 fresh E2B sandboxes from the hosted image built from this branch (4 vCPUs, 8 GB), 10 per tree. Every run checked in the child, with no deaths and no checker warnings.

    Tree Save job, slowest (gate: under 1.0 s) Session job from stage open, slowest (gate: 10 s at most)
    the kit's harness folder 12 ms 2.63 s
    futureagi ai_tools 10 ms 2.14 s
    futureagi, the whole repository 12 ms 5.77 s

    The save job here is the first save that reaches the child, timed cold, on a small honest contract. The gate image used HTTPS for its own apt step, because plain-HTTP downloads arrived corrupt on the network the image was built from. That is a build-only change and isn't in this PR.

Changes since the last clear review

The change review cleared in round 3. Everything after it had a fix-only check by one fresh reviewer, and both checks were CLEAR with no findings:

  • Round 3's Lows:
    • the save checks the clock before each send;
    • the session bound marks the child killed in the same lock hold;
    • the lock test adds a live second save.
  • The two test rows the planted bugs called for, and the merge of fix(harness): give a browser scan its limits cut a second look (TH-9512) #281's head (6cf081b). The merge's one conflict, in tools.py, keeps both sides. feat(harness): build an agent from a repo's subfolder #225's GC freeze in the three loop-stall tests survived it.
  • b4f4122: the two child-mode loop-stall tests now retry while the answer is "not checked in time", as the model would.
    • On a loaded Mac (load average 17-26), test_a_crafted_script_does_not_hold_up_the_conversation failed 2 of 3 runs. The session job outlasted the deadline, and the save rightly answered that it was still running.
    • Both tests still check that the event loop is never held, and that the last answer is a real save.

Known limits

  • A save made as the stage opens can be cut. The session job took more than 1.5 s in 6 of 10 runs on the kit, 6 of 10 on ai_tools and 10 of 10 on the whole futureagi repository. A save that comes before it ends answers "not checked in time", and the model writes the contract again.
  • On Python 3.10, wait_for can swallow a cancel that lands in the same tick as the answer. On 3.11 and later the code uses asyncio.timeout_at. CI runs 3.12 only, so it wouldn't catch a revert to wait_for either.
  • A trial killed mid-way leaves its folder in the temp directory.
  • Three details of a cut aren't pinned by tests:
    • an answer that arrives in the deadline's own tick wins over the cut;
    • the problems an earlier stage set aside are kept on a cut;
    • the deadline WARNING lists the stages that report no source.
  • On macOS the watchdog runs ps every 50 ms. On Linux it reads /proc.
  • SAVE_DEADLINE is read when the tool server is built, so changing it later has no effect on that server.
  • The crafted worst-case layout wasn't measured on hosted. On the laptop its save job takes 0.54-0.67 s on 3.12.3.
  • Older than this PR, not changed by it:
    • the tokenizer's work charge counts each token's whole line, so a long line of short tokens can spend the scan's budget and later files go unread;
    • the import walk's memo keeps four misses per import name (bundling only);
    • the 4,096 name limit counts characters, while the system limit counts bytes;
    • the Compose profile skip compares profiles as written, so a profile given by a variable isn't checked;
    • no test checks that an oversized prompt file counts towards the 4 MiB total;
    • on Python 3.13 and later the path resolver raises on a link loop where Path.resolve() doesn't (the sandbox runs 3.12.3);
    • a coding cookie after many failed cookie places on one line is read as a refused codec.

Left alone

  • Bundling's checks keep running as today, in-process with their limits. A deadline child of their own is left for later.
  • Lifting the time-only limits one by one, now that the deadline backstops them, is a follow-up.

The browser scan stopped reading an agent's code at any of its limits
(file size, the scan, script and Python totals, the parse total, the
token total and work, the decode and search totals, a skipped file, a
package.json it couldn't read) and returned what it had, so padding read
first or a large honest repository hid a plain browser import, silently.

The scan now returns the limits that cut it, and a cut reading gets one
second look with its own message before the model's answer stands; a cut
never refuses on its own. One WARNING per cut scan names the limits and
counts. The import walk's own stops are left to its own fix.

Refs: TH-9512
Signed-off-by: khushalsonawat <khushalsonawat@gmail.com>
Signed-off-by: khushalsonawat <khushalsonawat@gmail.com>
… (TH-9512)

Signed-off-by: khushalsonawat <khushalsonawat@gmail.com>
…total

Brings in #269, #276 (through #283), #283 and #288. Three hunks against
#288 (TH-9448) were resolved by hand, keeping both intents:
- a skipped file logs its type once at WARNING, and its path and trace
  at DEBUG (#288); it is also counted as the scan's `skipped` cut, and
  that count is in the cut line, so #288's separate "skipped N files"
  line is not repeated (TH-9512);
- #288's skip tests and its NUL-byte test now expect the cut line, which
  holds names and counts only.

Tests on 3.12.3: test_agent_type_refusal 720, test_browser_scan_cut 59,
test_contract_tools_subfolder 180, all passed.

Signed-off-by: khushalsonawat <khushalsonawat@gmail.com>
TH-9445

Signed-off-by: khushalsonawat <khushalsonawat@gmail.com>
… read package.json as Node does (TH-9512)

- An oversize .py counts as file_size only when its bytes name a browser package, its coding
  line names a codec that isn't raw-safe or that Python refuses, or the search can't clear it:
  at most 4 MiB a file and 8 MiB a scan are searched, and a file past either bound, unreadable,
  not a file, or grown since its size was taken still cuts. It is never parsed.
- package.json is read as Node reads it, UTF-8 with replacement characters and integers kept as
  text; invalid JSON means no dependencies, since npm refuses it too. Only OSError and
  RecursionError leave it unreadable, a cut.

TH-9512

Signed-off-by: khushalsonawat <khushalsonawat@gmail.com>
…t/TH-9512-past-the-parse-total

Signed-off-by: khushalsonawat <khushalsonawat@gmail.com>
…H-9512)

- A file exactly at the per-file search bound, its needle in the last bytes, cuts.
- An oversize module whose only tool is PlayWrightBrowserToolkit cuts.
- Past what's left of the scan's search total, an oversize file isn't opened.

TH-9512

Signed-off-by: khushalsonawat <khushalsonawat@gmail.com>
…the-parse-total

agent_type_check.py, the skip handler: #291 drops the trace at every level; this branch
counts a skip as the skipped cut. Kept both: no trace, the type once at WARNING, the count
in the cut line.

Signed-off-by: khushalsonawat <khushalsonawat@gmail.com>
…deadline (TH-9445)

The save read the customer's files in a thread, and Python's parser holds the GIL, so a
crafted repository could hold the hosted conversation for seconds. The checks now run in a
forkserver child, one per builder session, with a 1.5 s deadline for each save.

- A save cut at its deadline saves nothing. It answers with the problems that arrived, or
  "not checked in time".
- The browser scan and instrumentation run once per session in the child, with their own
  30 s bound. The answer is cached, and no save's deadline kills that job.
- The child runs today's _checked_contract, so the in-thread fallback and the child share
  one code path. The fallback is used when forkserver is missing, when the start-up trial
  fails, after two deaths in a row, for a second event loop, and for a closed checker.
- Every read limit stays. A watchdog kills a child past 400 MB. Logs come back on the
  answer pipe.
- Tests that patch checks the child runs use save_checks="thread". Both new test files
  are in sdk-smoke.yml.

TH-9445

Signed-off-by: khushalsonawat <khushalsonawat@gmail.com>
… into TH-9445

TH-9445

Signed-off-by: khushalsonawat <khushalsonawat@gmail.com>
… checks run (TH-9445)

On a loaded host the crafted files' session job can outlast the 1.5 s save
deadline, so the first save answers "not checked in time" and the model would
write again. Both child-mode loop-stall tests now do the same, every 0.5 s up
to 60 tries, and still check that the loop is never held and that the last
answer is a real save.

Signed-off-by: khushalsonawat <khushalsonawat@gmail.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant