Skip to content

emergent_testing: keep what the shared-runner attempt taught, revert the code - #1737

Merged
sergey-shandar merged 19 commits into
mainfrom
claude/todo-unification-runners-7wvd1q
Aug 27, 2026
Merged

emergent_testing: keep what the shared-runner attempt taught, revert the code#1737
sergey-shandar merged 19 commits into
mainfrom
claude/todo-unification-runners-7wvd1q

Conversation

@sergey-shandar

@sergey-shandar sergey-shandar commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

This PR no longer changes any code. The diff against main is documentation
only; every .mjs, .ts and .json file is byte-identical to main.

An earlier revision of this branch implemented
emergent_testing/todo/share-browser-console-runner.md: one shared
runModuleMap, a Reporter per host, an effects/common layer, a browser
interpreter, 100% coverage, 19 green checks, and 3435 proofs executed in
Chromium. It worked. It is reverted anyway, because how it got there is a cost
worth not paying twice, and the record of why is worth more than the code was.

What is kept

emergent_testing/todo/share-browser-console-runner.md — restored, and with
a new "How to do this — read before designing" section. The issue's design was
never the problem; the order of work was. It now says so:

  • fjs t is the specification, including the things it does not do. The
    attempt shared the modules and then let the browser keep its own test-name
    format, its own scheduling policy and its own clock. That looks like success
    and is not: one name over two behaviours is worse than two names over two
    implementations, because nothing signals the difference.
  • fjs t is sequential, and that is a decision to copy. The attempt
    invented a batch size nobody asked for and no measurement motivated. The
    section traces what followed, in order — a batching bug, a deadlock on a graph
    fjs t completes, a flaky proof, a proof that passed for the wrong reason, a
    MessageChannel yield needed only because setTimeout clamps, and finally a
    bun failure caused by that MessageChannel — six review rounds, all downstream
    of one constant that was ultimately deleted. The end state, no batching at all,
    is where copying the example would have started.
  • A problem the port reveals is everyone's problem. Timing accuracy and
    hostile values are not browser bugs to work around locally; sandbox is
    shared, so each is one decision for both hosts, taken as its own change.
  • Keep the change reviewable. 2646/1408 across 35 files in one PR is why the
    scheduling argument could not be separated from the sharing argument.

It also gains constraints the attempt had to learn the hard way: both runners
must produce the same test name for the same leaf; no host-specific behaviour
fjs t does not already have; and a proof must assert the property rather than
an engine's incidental scheduling, because node, deno and bun do not agree on
the ordering of timers against other task sources.

DESIGN.md §4, "Follow the example" — the general rule. "Reuse code" is
satisfiable while still getting the important half wrong. When a capability
already exists and is being brought to a second context, the existing one is the
specification: reproduce it first, including its simplifications; a difference
has to be justified in an issue rather than merely noticed; and a problem the
new context reveals is fixed for the shared code or recorded — never worked
around in one host.

Four issues the attempt surfaced, each rewritten to describe the code as it
is on main rather than as the branch left it:

  • hostile-proof-values.md — the traversal and errorDetails read user values
    with no guard, and a catch operation beside sandbox is the design. The
    browser has partial cover here and fjs t has none, so unifying forces one
    answer.
  • imports-promises-realms.md — a namespace adopts a then, a proof tree
    refuses to, and instanceof does not survive a realm. The browser's
    Symbol.species machinery is the only place the third is covered today.
  • report-before-running.md — every runner names a test only after it finishes,
    which is exactly wrong when a run crashes.
  • timer-precision.mdperformance.now() is coarsened to 100 µs in Chromium
    and rounded and jittered to 1 ms in Firefox, at or above what a typical proof
    takes, so per-proof durations there are largely the clamp.

Verification

git diff origin/main -- '*.mjs' '*.ts' '*.json' is empty. The reverted tree is
main's tree, so fjs t, bun test and coverage are main's results.

Changelog: no user-visible change — documentation only.

🤖 Generated with Claude Code

https://claude.ai/code/session_016PyLwDNkPQM1uD1Tg5ApBg

The browser runner and `fjs t` implemented the same proof semantics twice —
leaf discovery, tree walking, the structural `throw` expectation, promise
resolution, path formatting, counting — in `emergent_testing/browser.mjs` and
`emergent_testing/module.f.mjs`, and had begun to drift.

They are now one runner. `runModuleMap` is the single source of truth; a host
supplies a `Reporter` and an effect interpreter and nothing else.

- `fjs/effects/common/` holds the operations no host owns — `all`, `await`,
  `fetch`, `import`, `now`, `sandbox` and the `IoError` helpers — moved out of
  `fjs/effects/node/`, which re-exports every one of them unchanged.
- `fjs/effects/browser/module.mjs` interprets exactly that set against a browser
  realm, and takes the composed runner so a page can add operations of its own.
- `emergent_testing`'s new `report`/`reported` operations and
  `recordingReporter` normalize each leaf into a `TestResult` carrying no
  terminal text and no DOM.
- `emergent_testing/browser/module.f.mjs` is the pure browser application —
  link, run, report — provable from Node with a stand-in interpreter;
  `emergent_testing/browser/module.mjs` is left with the DOM, the published
  promise and the completion event.

Verified end to end in Chromium: 3435 proofs, all passing, rendered and
published from the generated page.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PyLwDNkPQM1uD1Tg5ApBg
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
functionalscript b9b273c Commit Preview URL

Branch Preview URL
Aug 27 2026, 10:55 AM

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for security reviews. Please try again later.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 01f29ad7fe

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread fjs/emergent_testing/browser/module.mjs Outdated
Comment thread fjs/emergent_testing/browser/module.mjs Outdated
Comment thread fjs/emergent_testing/browser.mjs
…without a report

Two defects found by review, both reproduced first and both regressions
against the deleted runner.

`all` started every child in the same turn, so the macrotask the `report`
handler awaited paused only that result's own continuation while its siblings
ran on: no task boundary, and a page frozen on its first frame until the last
proof body finished. The slicing belongs to the browser interpreter, where the
scheduling is, so `all` now runs its children 25 at a time and yields between
slices; the adapter's counter is gone.

Reading the tree a proof returns runs user code, and the shared traversal has no
`try`/`catch` to give it, so a throwing getter panicked through the run: the
promise rejected, the page stayed in `running`, and no report or completion
event ever arrived — the one outcome an automated controller cannot act on. The
adapter now turns that panic into an `infrastructure-error` report.

Attributing such a failure to the leaf that caused it, rather than to the run,
stays in `todo/hostile-proof-values.md`, which is updated to say what is now
handled and what is not.

Verified in Chromium: loading progress and result rows advance throughout the
run (349 → 3435) instead of appearing only at the end.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PyLwDNkPQM1uD1Tg5ApBg

@o2alexanderfedin o2alexanderfedin left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Measured at b4575877a (head has since moved to 0aac2255). Gates green: npm test 3481/3481 exit 0 vs main's 3470/3470, tsc --noEmit exit 0 on both.

The unification is real, not a re-skin: fjs/emergent_testing/module.f.mjs holds the semantics, and both entry points import it — node via fjs/module.f.mjs:11testAll(defaultReporter)runModuleMap, browser via browser/module.mjs:35browser/module.f.mjs:28, which imports recordingReporter, reported and runModuleMap from the same module. share-browser-console-runner.md is deleted because it shipped, with its unshipped half correctly moved to website/todo/website-preparation-program.md and zero dangling references. Changelog: is literal and matches the entry file. fjs/effects/browser/module.mjs is new but is exercised by npm test.

Four findings:

  1. A throwing getter in a proof tree now kills the whole run — undocumented, and a regression. Probe at head: the producing leaf prints ok, then Error: getter … at Function.entries … at parseTestSet (module.f.mjs:83) … at collectTests (module.f.mjs:100) escapes outside any sandbox and no Number of tests summary is printed at all. Main proved the opposite in exportedTreeThrows/returnedTreeThrows (one failed test, run still reaches a terminal state). todo/hostile-proof-values.md covers errorDetails and cross-realm promises but never mentions parseTestSet/Object.entries over a hostile tree, so this one is not among the trade-offs the PR declares.

  2. Six leaves are lost outright, and two of them cover live behaviour that is now unproven: sourcesLoadingSummaryIsSynchronous and sourcesProgress — the Loading n/m text still exists in browser/module.mjs and still carries the comment defending the synchronous write, but grep Loading browser/proof.mjs returns nothing, so the bug fixed two PRs ago is now unpinned. Also gone: runControlNewRunAfterCompletion (re-running on the same root), reportingThrows, exportedTreeThrows, returnedTreeThrows. The other 37 map cleanly — 14 moved effects/nodeeffects/common verbatim, 10 browser renames, 4 folded into proof.helpers.errorDetails.*, 9 dropped deliberately and documented.

  3. sandbox and io are duplicated byte-identically between the two interpreterseffects/node/module.mjs:254-272 vs effects/browser/module.mjs:76-94 (diff empty), likewise node:99-102 vs browser:57-60, and awaitPromise at node:275 vs browser:109. The browser file's own comment says "exactly as the Node runner does". That is the drift this PR set out to remove, surviving in the one place there is no impure effects/common/module.mjs to hold it.

  4. The browser suite is still unreachable from CI — third PR in a row. Measured, not inferred: added a failing .f.mjs proof, npm run websiteexit 0, and the failing module is present in the generated _browser-suite.mjs. npm test exits 1 only because fjs t runs the same file, which was already true on main. Nothing in .github/workflows/ci.yml starts a browser and nothing consumes fjsBrowserTestReport or fjs-browser-test-complete. Unifying the runners was the natural moment to close this; it is still open.

Two notes, not blocking. The cross-realm rejection reported as a pass reproduces exactly as the changelog declares (proof.crossRealmRejection(): ok, then the unhandled rejection ends the process with no summary) — deliberate, but worth stating plainly that main asserted tests: 2, failed: 1 there. And a pre-existing gap this PR does not touch: making collectTests skip every leaf whose key starts with e survives npm test at exit 0 with 3131 of 3481 proofs silently gone — nothing anywhere asserts a proof count. Killing that would need one assertion, and it would have caught finding 2 as well.

…iew found missing

`sandbox`, `io` and the `await` body were byte-identical in the Node and browser
interpreters, with a comment in one saying it matched the other — the drift this
PR exists to remove, surviving where there was no impure module to hold it.
`effects/common/module.mjs` holds them now, with its own proofs: nothing in them
touches a host, and `sandbox` in particular is the operation that executes a
proof body, so a runner spelling it its own way would make one suite mean
different things in two hosts.

Four proof leaves that the move dropped are back, and two of them pin live code
that had gone unpinned: `loadingSummaryIsSynchronous` and `loadingProgress`
defend the synchronous `Loading n/m` write, and `newRunAfterCompletion` covers
re-running on the same root. `renderingThrows` came back with the guard it
proves — showing a result is the page's own code, and a renderer that throws
must not cost the report every consumer is waiting for; the result is recorded
before it is rendered.

`browser-testing.md` records what the review measured: the "demonstrably execute
inside browsers" gate for a CI job is met, the controller is what still blocks
one, and no runner asserts a floor on the number of proofs it discovers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PyLwDNkPQM1uD1Tg5ApBg

Copy link
Copy Markdown
Contributor Author

Thank you — this is the review that found the two things I had wrong. Head is now dc10dcf. Taking them in order.

1. Throwing getter — half already fixed, half not this PR's

You measured b4575877; 0aac225 landed a few minutes later and changed the browser half. The page no longer hangs: the adapter converts the panic into an infrastructure-error report before rendering and dispatching, and hostileProofTree pins the status, the message, the state progression and the completion event. The todo also names collectTests/parseTestSet over a hostile tree explicitly now — you were right that it only mentioned errorDetails.

On fjs t, though, I have to push back on "regression". I checked out origin/main's fjs/ and ran your probe against it:

$ node .../fjs/module.mjs t          # main's tree
import("./a.proof.f.mjs").proof.producer(): ok, 0.0161 ms
import("./a.proof.f.mjs").proof.other(): ok, 0.0045 ms
<throw, no summary>                  exit=1

Identical. git diff origin/main -- fjs/emergent_testing/module.f.mjs touches one doc comment and nothing else — runModule, walk, collectTests and parseTestSet are byte-for-byte main's — so fjs t behaves exactly as it always has here. What main proved in exportedTreeThrows/returnedTreeThrows was the browser runner's separate guard, and that is the regression, now partly repaid.

What is genuinely still worse than main is attribution: main lost one test, this loses the run. I looked hard for a small fix and did not find one. Sandboxing the traversal is the obvious move and does not work — sandbox(() => collectTests(…, fn())) reads the tree synchronously before the runner awaits, so promise sub-trees break. Doing it properly means the sandbox operation returning an already-read tree, which changes an operation both hosts and the virtual runner implement — and the virtual runner's sandbox is a deliberate pass-through, so it needs a decision, not a patch. That is written up in todo/hostile-proof-values.md rather than attempted here. It would fix fjs t too, which main never did.

2. Lost leaves — four back, two of them the ones that mattered

You were right that Loading n/m still carried the comment defending it while nothing pinned it. Restored: loadingSummaryIsSynchronous, loadingProgress, newRunAfterCompletion, and renderingThrows — the last with the guard it proves, since I had dropped that too: a result is now recorded before it is rendered, so a page renderer that throws costs the display and not the report. I checked the guard is load-bearing by removing it — renderingThrows fails.

The remaining two are exportedTreeThrows/returnedTreeThrows, which are finding 1 and cannot come back until attribution does.

3. Duplicated sandbox/io/awaitPromise — the fairest hit in the review

Entirely right, and the comment saying "exactly as the Node runner does" is exactly the tell: a comment is not a mechanism. fjs/effects/common/module.mjs holds all three now, with its own proof.mjs, and both interpreters import them. Nothing in it touches a host — a bare realm has Promise, a clock and a catch — and sandbox is the operation that actually executes a proof body, so it is the last thing that should exist twice. effects/README.md says why it is there.

One thing I found while proving it and did not change, because it is beyond this PR: Sandbox declares SandboxResult<T> while every runner resolves a real promise first, so a thunk returning Promise<number> is typed as holding a promise where it holds a number. Awaited<T> is the honest spelling and type-checks cleanly across the tree — say the word and it is a one-line follow-up.

4. Browser suite unreachable from CI — true, and not this PR

No argument on the facts: nothing in .github/workflows/ starts a browser, and npm run website only generates the manifest, so it exits 0 with a failing proof in it. But closing it needs fjs browser-test — the loopback server, browser launch, timeout and infrastructure-error classification — which is browser-testing.md's own unticked task, not a wiring job this PR can absorb.

What this PR did change is the gate. That todo said "add CI only after proof bodies demonstrably execute inside browsers", and they now demonstrably do: 3435 proofs linked and executed in Chromium over the generated page. I have recorded that in browser-testing.md — the gate is met, the controller is the blocker, and the npm run website exit-0 observation is written down so the next person does not have to rediscover it.

The two notes

Cross-realm rejection. Stated plainly, as asked: main asserted tests: 2, failed: 1 there, and this reports it as a pass. Deliberate, declared in the changelog and in todo/hostile-proof-values.md, and it is the same answer fjs t has always given — but it is a capability the browser had and no longer has, not a wash.

No proof-count floor. Reproduced and genuinely alarming: 3131 of 3496 proofs can vanish at exit 0. Pre-existing, and it belongs to the suite rather than to this diff, so I have added it as a task on browser-testing.md rather than smuggling an assertion in here. You are right that it would have caught finding 2 — that is the argument for doing it properly.

npx tsc clean, fjs t 3496/3496, npm run cov 100% on every dimension.


Generated by Claude Code

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: dc10dcf160

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread fjs/emergent_testing/browser/module.mjs Outdated
Comment thread fjs/effects/browser/module.mjs Outdated
Comment thread fjs/emergent_testing/browser/module.mjs Outdated
…sing every infrastructure error as a load failure

The guard added for a panicking proof tree was not itself total: describing the
panic reads the value that caused it, so a proxy whose traps throw *itself* made
`errorDetails` panic in turn and the page stuck in `running` again — the guard
becoming the thing it was meant to prevent. It is the last handler there is, so
it now says what it cannot describe rather than being thrown by it.

`infrastructure-error` covers a run that panicked and a runner missing an
operation as well as a module that would not link, so the summary no longer
claims they all "failed to load" — a false diagnosis sends a reader to debug
their imports. Each result still carries its own module and message.

The browser clock reads `performance.timeOrigin + performance.now()` rather than
`Date.now()`. The operation means the same thing — milliseconds since the epoch,
as the Node runner answers — but a suite runs for minutes, and a report's
duration is the difference between two reads: with wall-clock time an NTP
correction inside a run makes that negative or inflated, which is what the
deleted runner avoided by measuring in `performance.now()`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PyLwDNkPQM1uD1Tg5ApBg

@o2alexanderfedin o2alexanderfedin left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Measured at dc10dcf16. Gates green: npm test 3496/3496 exit 0 vs main's 3470/3470, tsc --noEmit exit 0 on both.

Two of the four are closed.

F3 fixed. effects/node/module.mjs:33 and effects/browser/module.mjs:22 both now import { awaitPromise, io, sandbox } from '../common/module.mjs', and a grep for local redefinitions in either file returns nothing. effects/common/proof.mjs is discovered and run — 9 leaves (io.value/.thrown, sandbox.value/.thrown/.thenable/.promise/.rejected, awaitPromise.plainValue/.promise).

F2 mostly fixed, and the decisive one bites. The names came back restructured rather than verbatim: loadingSummaryIsSynchronous, loadingProgress, newRunAfterCompletion, renderingThrows, hostileProofTree. Deleting the synchronous summary.textContent = \Loading 0/${sources.length}`write atbrowser/module.mjs:115is **KILLED** — 3495 pass / 1 fail,proof.loadingSummaryIsSynchronous(): error— so that is a real restoration, not a name. Still with no counterpart:exportedTreeThrowsandreturnedTreeThrows`, which is finding 1 below in proof form.

F1 still stands, and the mitigation landed on the wrong side. The new hostileProofTree proof exercises the browser's startBrowserTestSources; the finding was about the fjs t path, and collectTests/walk in emergent_testing/module.f.mjs are still unguarded. Re-measured by inserting get hostileGetter() { throw new Error('trap-f1') } into the exported proof object of fjs/proof.f.mjs and running the full npm test — uncaught crash, no Number of tests summary, Node's default crash exit, same stack as before:

Error: trap-f1
    at get hostileGetter (fjs/proof.f.mjs:25:33)
    at Function.entries (<anonymous>)
    at parseTestSet (fjs/emergent_testing/module.f.mjs:83:31)
    at collectTests (fjs/emergent_testing/module.f.mjs:100:17)
    at walk (fjs/emergent_testing/module.f.mjs:205:25)

Main proved the opposite through exportedTreeThrows/returnedTreeThrows. Since the two runners are now one, guarding parseTestSet would cover both paths at once and restore those two leaves.

F4 unchanged — fourth PR in a row. Added a failing leaf to fjs/proof.f.mjs: npm run website exits 0 while the failing module is in the generated _browser-suite.mjs ("browser proof modules: 139 of 139"), and grep -rl 'fjsBrowserTestReport\|fjs-browser-test-complete' .github/ still finds nothing. Nothing in CI gates on the browser suite's actual result.

…raversal

Review pressed on the one gap left, and correctly: since the two runners are now
one, guarding the proof-tree traversal once covers `fjs t` and the browser
together. The todo carried a vague "read user values behind `sandbox`" and one
wrong reason for why that does not work.

Timing is not the obstacle — the sub-tree walk runs after the leaf's promise has
resolved, so a synchronous thunk over a settled value would be fine. The
obstacle is the virtual runner: its `sandbox` is a deliberate pass-through,
because a `.f.mjs` runner has no `try`/`catch` to implement a real one with, and
routing the traversal through it would break every fixture.

So the design is a second, honest operation beside it — `catch`, "run this pure
thunk; a throw is the `error` branch" — with the file-by-file work and the
proofs it restores written down.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PyLwDNkPQM1uD1Tg5ApBg

Copy link
Copy Markdown
Contributor Author

Thanks — and thank you for mutation-testing the restored proof rather than taking the name for the thing. Head is 68fc983.

F1 — you are right that unification makes this one fix, and I had the reason wrong

I told you earlier that sandbox could not hold the traversal because of promise timing. That was wrong, and it matters for whoever implements this, so: the sub-tree walk in runModule happens after the runner has resolved the leaf's promise, so sandbox(() => collectTests(path, false, r)) would run a pure synchronous thunk over an already-settled value. Timing is not the obstacle.

The actual obstacle is the virtual runner. Its sandbox is a deliberate pass-through —

sandbox: f => state => [state, ok(/** @type {SandboxResult<unknown>} */ (f()))],

— with the fixture returning the SandboxResult it wants reported, because effects/node/virtual is .f.mjs and FunctionalScript has no try/catch to implement a real one with. Route the traversal through sandbox and that handler receives a thunk answering _TestAndPath[], casts it to SandboxResult, and every fixture in emergent_testing/proof.f.mjs breaks.

So the fix is a second, honest operation beside it, and I have written the design into todo/hostile-proof-values.md rather than leaving you to rediscover this:

export type Catch = readonly['catch', <T>(f: () => T) => OpResult<Result<T, unknown>>]

"Run this pure thunk; a throw is the error branch." No clock, no fixture convention, so every runner implements it truthfully — tryCatch(f) in the Node and browser runners, one line each; ok(ok(f())) in the virtual one, which still cannot catch and makes the same bargain sandbox already does. walk then reads a sub-tree through it and reports one failed result at that path instead of panicking, which restores exportedTreeThrows/returnedTreeThrows and gives fjs t a behaviour it never had.

I am not pushing it here. It adds an operation to the shared vocabulary, touches all three runners and both mock maps, and changes what walk can answer — that is a design decision on a PR that is already 28 files and green, not a fix to fold into a review round. The todo now carries the file-by-file work and the task list; say the word and it is the next PR, or tell me to land it here and I will.

What I did settle in this PR is the half that has no design question: the browser always terminates with a report, and the guard that does it is now itself total (57e295b, after the self-throwing-proxy finding).

F4 — no argument, and the todo now says so in your terms

Everything you measured is accurate and I have recorded it in browser-testing.md rather than only in a thread: the gate that todo set for itself ("add CI only after proof bodies demonstrably execute inside browsers") is now met — 3435 proofs linked and executed in Chromium over the generated page — so what blocks a CI job is the fjs browser-test controller, not evidence. The npm run website exit-0 observation is written down there too, so the next person does not rediscover it.

Your proof-count note is also on the list there, in your framing: nothing in any runner asserts a floor, so a collectTests that silently skipped most leaves keeps fjs t at exit 0. You were right that it would have caught F2.

Where the PR stands

All 19 checks green on the current head. npx tsc clean, fjs t 3498/3498, npm run cov 100% on lines, branches and functions.


Generated by Claude Code

@o2alexanderfedin o2alexanderfedin left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved. Verified at 68fc98372: npm test 3498/3498 exit 0, tsc --noEmit exit 0 (main 75ee38397: 3470/3470).

Both commits are sound as scoped. The panic guard is real and load-bearing, not decoration: reverting browser/module.mjs:186-189 to a bare errorDetails(error) call is KILLED — the new unreadableFailure leaf reports error and, more tellingly, a later unhandled rejection then crashes the process with no Number of tests summary, which is exactly the failure signature the guard exists to prevent. The classification change is asserted too, by infrastructureSummaryNamesNoCause, not merely stated.

On the traversal: 68fc98372 designs the fix rather than making it, and says so. I re-ran my probe and the crash is unchanged — but todo/hostile-proof-values.md now scopes it correctly ("the whole run is lost … fjs t still ends with a stack trace and no summary"), names exportedTreeThrows/returnedTreeThrows as the leaves to restore, and is Status: open with all four tasks unchecked. That is the right way to defer it, and it clears the concern I raised about the mitigation landing on the browser side only.

The two open items stay open, neither introduced here: the unguarded collectTests traversal above, and the browser suite still being unreachable from CI — re-measured, npm run website exits 0 with a failing leaf baked into the bundle and .github/ consumes neither fjsBrowserTestReport nor fjs-browser-test-complete.

@sergey-shandar

Copy link
Copy Markdown
Contributor Author

This is good PR but still far away from what it should look like. The first obvious observation is the formatting of messages. Everywhere (in test runners, in registers), we use the format import("./fjs/asn.1/proof.f.mjs").proof.encodeSmall(), but the browser runner uses a different format for some reason. Why? It means we are still far away from properly sharing code. The correct abstraction would be:

A common runner that sends some generic effects (currently, sandbox, log, import, etc.). If the browser can't work with some effects like log, we lift an abstraction. Replace the log with an artificial effect (e.g. testReport). Or use dependency injection, where the runner accepts a set of functions that do the required job, e.g. T, { log: (msg: string) => Effect<T, void>, import: (source: string) => Effect<T, Module>, ... }.

I don't propose changing it now, but we definitely need another TODO to share more code and logic across different platforms.

Two more things,

  • we need a better strategy for reporting instead of reporting in batches of 25. Some tests are fast, some are very slow, and sometimes the logging gets stuck for a while. Using delta time would be much better. Mitigation: reduce to 10. For a proper solution, create a TODO to investigate.
  • Create a separate TODO with import, promises, and realms. It can be non-actionable, but it definitely requires additional investigation. Current code looks like a magic mess.

… review asked for

The page rendered `./a.proof.f.mjs .x` where the terminal rendered
`import("./a.proof.f.mjs").proof.x()` — one identifier in two spellings, months
after the semantics were shared, because rendering was still per host. The
format now lives once, in `fmtCall`, which takes a module and an
already-rendered key chain so a reporter holding a `TestResult` names a test
exactly as `fjs t` does; `fmtImport` is that function over an unrendered `Path`.
`passing` asserts the rendered line, so the two cannot drift again.

The browser's `all` yields every ten effects rather than every twenty-five. A
count measures the wrong thing — proofs differ in cost by orders of magnitude,
so ten fast ones waste a boundary and one slow one stalls the page anyway — so
this is a mitigation and is labelled as one, with the elapsed-time design in
`todo/report-scheduling.md`.

Three TODOs, none of them changes here:

- `share-the-whole-runner.md` — the semantics are shared but the runner around
  them is written per host: discovery, reporting and the outcome. Compares an
  artificial effect per capability against injecting the host's verbs, with the
  formatting drift above as the symptom to keep in mind.
- `report-scheduling.md` — yield on a time budget instead of a count.
- `imports-promises-realms.md` — a study, not a design: a module namespace
  adopts a `then`, a proof tree refuses to, and `instanceof Promise` does not
  survive a realm. Three mechanisms whose interaction nobody has written down,
  which is why it keeps being rediscovered.

`hostile-proof-values.md` hands the cross-realm brand check to the last of
those rather than sketching it twice.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PyLwDNkPQM1uD1Tg5ApBg

Copy link
Copy Markdown
Contributor Author

All four done in 505766d.

The formatting — you are right, and it is the sharpest possible criticism of this PR

The page rendered ./a.proof.f.mjs .x where the terminal rendered import("./a.proof.f.mjs").proof.x(). One identifier, two spellings, in a PR whose whole claim is that the runners no longer disagree. The semantics were shared and the rendering was not, so the drift simply moved one layer up — which is exactly your point that this is still far from properly sharing code.

The format now lives once:

export const fmtCall = (file, path) => `import(${JSON.stringify(file)}).proof${path}()`
export const fmtImport = (file, path) => fmtCall(file, fmtPath(path))

fmtCall takes an already-rendered chain, because a reporter holding a TestResult has one; fmtImport is the same function over a Path. The browser renders through fmtCall, and passing now asserts the rendered line rather than just the fields, so the two cannot drift apart again silently.

Batching → 10, with the real design written down

Done. And your diagnosis is the right one: a count measures the wrong thing. Ten fast proofs waste a task boundary; one slow proof stalls the page for as long as it runs and no count would have helped. So the constant is labelled a mitigation in the code, and todo/report-scheduling.md carries the elapsed-time design — keep starting children while the slice has spent less than a frame's worth, hand the loop back when it has, which self-tunes.

It also records the part I could not resolve by argument: a proof body is synchronous from the runner's point of view, so a budget bounds how many more start after a slow one, not the stall the slow one causes. Reporting a proof's start as well as its result may matter more, and is the cheaper experiment.

todo/share-the-whole-runner.md

Written with your framing, including both shapes you sketched — an artificial effect per capability (testReport, which report/reported already is, once), against injecting the host's verbs as { log, import, … }. It tabulates what each host's main still does separately (entry, discovery, reporting, outcome), notes that only two of those four differ for a real reason, and sets the test concretely: adding a third host must not mean writing a fourth main. The formatting drift is recorded there as the symptom that shows why this matters.

todo/imports-promises-realms.md

Written as a study rather than a design, as you said it could be. What it names is the interaction nobody has written down, which is why it keeps being rediscovered: a module namespace adopts a then (hence the export ban in spec/todo/3240-export.md), a proof tree refuses to (hence { then: f } being a sub-tree), and the check that separates them — instanceof Promise — does not survive a realm boundary. Three correct rules in three layers, and the magic mess is where they meet.

I agree with "magic mess" about the removed code, and the todo says so; it also says the removed species.proof.mjs cases should be recovered from this PR's history, so whatever replaces them is measured against real cases rather than a memory.

hostile-proof-values.md now hands the cross-realm brand check to that file instead of sketching it in two places.


npx tsc clean, fjs t 3498/3498, npm run cov 100%. This is on top of the approved head, so it re-requests CI; nothing here changes behaviour except the rendered name and the batch size.


Generated by Claude Code

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 505766d8a6

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread fjs/effects/common/module.mjs Outdated
@sergey-shandar

Copy link
Copy Markdown
Contributor Author

One more thing. It looks like batching is exactly the premature optimization that is the root of all evil, and it shouldn't have happened. Nobody complained that it is slow. I would prefer to see it in another experiment (not in this PR), to remove batching completely and see how it behaves and only after that (if needed) solve the problem. Let's adjust the todo about scheduling so that we try no bactching/scheduling at all. The same way our CLI runner works.

…han designing it

The batching is premature optimization. It was not added because anyone found
the suite slow — `fjs t` schedules nothing at all and nobody has complained —
but because a page that renders nothing until the run finishes looked wrong in
review. Observing a behaviour is not the same as someone having a problem with
it, and the count moved from 25 to 10 with no measurement on either side.

So the scheduling todo now asks for the opposite of a design: remove the
batching entirely, run the real suite in a browser, and look at what actually
happens — the page may paint anyway, since module loading is network-bound and
dominates the first seconds. Only if that produces a stall someone objects to is
there a problem to solve, and the elapsed-time argument is kept for that case
rather than presented as the plan.

The code says the same thing where the constant is defined, so a reader meets
the probation before the rationale.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PyLwDNkPQM1uD1Tg5ApBg

Copy link
Copy Markdown
Contributor Author

You are right, and I should say plainly what happened rather than defend it.

I added the batching in a review round because a bot pointed out the page renders nothing until the run finishes, and I confirmed that it does. But confirming a behaviour is not finding a problem: nobody reported a stall, and fjs t schedules nothing at all and has never drawn a complaint. Then I argued the constant from 25 down to 10 with no measurement on either side of the change — which is the exact shape of an optimization nobody can evaluate. Premature is the right word.

todo/report-scheduling.md is rewritten in 569636b to ask for the opposite of a design. It is now titled "Try removing the browser runner's batching entirely", and the first task is to delete the batching and the yield — all back to Promise.all over every child — run the real suite in a browser, and write down what actually happens. It names what I do not know:

  • whether the page stays blank at all, since module loading is network-bound and dominates the first seconds, which may be all the yielding a page needs;
  • if it does, for how long, and whether that matters to anyone reading a passing run;
  • whether anything but rendering depends on the yield.

Only if that produces a stall someone objects to is there a problem, and the elapsed-time argument is kept for that case rather than presented as the plan. It also notes that operations.allYieldsBetweenBatches pins the current behaviour, so removing the batching means removing that proof rather than weakening it.

The comment where batchSize is defined says the same thing, so a reader meets the probation before the rationale.

On this PR: I read "(not in this PR)" as scoping the removal experiment out, so the code still batches at 10 and only the todo changed. If you would rather the batching come out now — it is a small, clean deletion, and leaving in something that shouldn't have happened is its own cost — say so and it is one commit.


Generated by Claude Code

@sergey-shandar

Copy link
Copy Markdown
Contributor Author

BTW, 10 looks much, much better. Even if it runs longer, because it updates progress much more often, it looks much better. It confirms that batching is a wrong, premature optimization nobody asked for.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 569636be1c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread fjs/effects/browser/module.mjs Outdated
…ing about it

Ran the real suite in headless Chromium four ways, 3435 proofs each, sampling
how often the page visibly moves. Two findings, and the first contradicts what
"remove the batching" expected.

Removing the yield removes the progress: with plain `Promise.all` the first row
appears at 39.8 s of a 39.7 s run — nothing at all until the end. A browser
cannot paint between microtasks and every operation resolves through one, so the
whole suite is a single task. It is not faster for it.

The grouping was never the point; the clamp was. Yielding after every result —
what a reader actually wants, and what `fjs t` does — costs 2% over no yielding
at all when the yield is a `MessageChannel`. It cost 45% only through
`setTimeout`, which clamps to 4 ms once nested: 3435 results times 4 ms is the
whole difference. So batching was a workaround for a bad yield primitive, and
the workaround is what made grouping look necessary.

The todo carries the table and the change it indicates: delete `batchSize` and
`runBatched`, yield after each result over an unclamped primitive, and re-point
the proof at that. Not made here, per the request to keep it out of this PR.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PyLwDNkPQM1uD1Tg5ApBg

Copy link
Copy Markdown
Contributor Author

That observation was worth more than either of our arguments, so I ran the experiment rather than reasoning further. Headless Chromium, the generated page, 3435 proofs each time, sampling every 150 ms. "Progress steps" is how many distinct result-counts a reader ever sees — how often the page visibly moves.

all schedules run first row progress steps
no yielding at all (Promise.all) 39.7 s 39.8 s 2
slices of 10, setTimeout yield 40.2 s 3.5 s 49
every result, setTimeout yield 58.1 s 3.5 s 229
every result, MessageChannel yield 40.4 s 3.6 s 123

Your instinct was right, and your conclusion needs one correction.

Right: grouping is the wrong mechanism, and it should go. What a reader wants is what fjs t already does — one result, one update — and grouping only approximates that.

The correction: removing the yield does not give you that. It gives you nothing at all until the end — first row at 39.8 s of a 39.7 s run. A browser cannot paint between microtasks, and every operation resolves through one, so the whole suite is a single task with no frame in it. The run is not even faster for it (39.7 s against 40.2 s). "10 looks much better than 25" extrapolates toward more yielding, not none — and the numbers agree: per-result is better again.

What made grouping look necessary was my choice of yield, not the yielding. setTimeout clamps to 4 ms once nested; 3435 results × 4 ms is the 45% that per-result yielding appeared to cost. Over a MessageChannel the same per-result yield costs about 2% against no yielding at all. So the thing to delete is batchSize and runBatched — yield after every result, over a primitive with no clamp — and there is then no constant to argue about, which was the real complaint.

todo/report-scheduling.md (4923ddb) carries the table, both findings, and that as the change to make, plus re-pointing operations.allYieldsBetweenBatches at per-result yielding and checking scheduler.yield() where it exists.

I have not made the change here — you scoped the experiment out of this PR and I have kept to that. But it is now a deletion plus a three-line macrotask, measured, and it removes the thing you objected to rather than tuning it. Say the word and it is one commit on this PR; otherwise it is the first task of that todo.


Generated by Claude Code

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4923ddbf2b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

* @type {(module: string, message: string) => TestResult}
*/
const infrastructureResult = (module, message) =>
({ module, path: '', status: 'failed', duration: 0, message, stack: '' })

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve loader stacks in infrastructure results

In the generated website flow, when a native module import rejects with an Error, the browser import handler reduces it to an IoError and this helper always emits stack: ''. The deleted runner passed the rejection through errorDetails and retained error.stack, while fjs/emergent_testing/todo/browser-testing.md still requires failures to retain their stack; consequently load failures now lose the source location needed to diagnose the broken dependency graph.

AGENTS.md reference: AGENTS.md:L103-L106

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified, and it is a regression rather than a declared trade-off — recorded in 0773613.

The measurement, against the browser import handler with a rejecting loader:

effect channel : ["error",["ioError",{"message":"Unexpected token in ./broken.f.mjs"}]]
what main kept : "SyntaxError: Unexpected token in ./broken.f.mjs" (+ frames)

The cause is one level below infrastructureResult's stack: ''. Linking is an Import effect now, so its failure is an IoError{ code?, message } — and toIoError drops the stack before the pure application ever sees it. There is nothing infrastructureResult could put there.

You are also right that it matters rather than being cosmetic: for a broken dependency graph the loader's frames are what name the importing module and line, which the message alone does not.

The fix is one additive optional field, stack?: string on IoErrorInfo, filled by toIoError and read by infrastructureResult. Worth saying why that is not a violation of the type's own rationale: IoError drops the thrown object because "a stack, a cause, and arbitrary own properties do not survive a wire hop" — which is right about the last two and wrong about a stack, since a stack is a string and serializes fine. The one subtlety is that reading .stack is a user-observable operation on a hostile value, the same exposure toIoError already has reading .message.

I have not made the change: it is a field on a type shared by every runner and every consumer of the effect layer, and the repository owner has twice asked me not to widen this PR. It is written up as a task on todo/browser-testing.md, beside the requirement it breaks, with the patch shape above. Land it here in one commit or take it as a follow-up — either is fine, and it is his call rather than mine.


Generated by Claude Code

The slice loop awaited each slice before starting the next, which broke `all`'s
concurrency promise rather than delaying it: a child waiting on something a
later sibling produces waited for a sibling that was never started, and the run
hung with no report and no completion event — on a graph the Node runner
completes. Reproduced with a proof that waits on a gate its eleventh sibling
opens; it hangs before the fix and passes after.

The loop now starts every effect, yielding between one slice's launch and the
next, and awaits them all at the end. That keeps the paint boundary — what a
slice does when it starts is exactly the work worth bounding, since a proof body
runs synchronously inside `sandbox` before that handler's first `await` — while
restoring the concurrency. `allStartsEveryChildBeforeAwaiting` pins it.

It also corrects the scheduling todo, whose measurements were taken against the
serializing loop. "Yield after every result" was the wrong way to describe the
target: per-result yielding *is* sequential execution, a bigger break than the
batching it was meant to remove. Per-*launch* yielding is the right shape, costs
about 3% over no yielding at all, and reaches the first row sooner.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PyLwDNkPQM1uD1Tg5ApBg

@o2alexanderfedin o2alexanderfedin left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approval still stands. Verified at 4923ddbf2: npm test 3498/3498 exit 0, tsc --noEmit exit 0 — unchanged from the approval point, as expected for three commits that add todo documents and one refactor.

The module.f.mjs change is behaviour-preserving where it matters and fixes a real drift where it does not: fmtCall(file, path) is extracted from fmtImport, which now calls fmtCall(file, fmtPath(path)) with the same template. Sorted proof leaf names across full runs at 68fc98372 and 4923ddbf2 differ in zero of 3498 lines. The actual behaviour change is that renderResult in browser/module.mjs now uses fmtCall too, so the page stops rendering ./a.proof.f.mjs .x where the terminal renders import(...).proof.x() — and that is pinned by a new assertion rather than left to inspection.

The three new todos all describe remaining work, all Status: open, and their claims about the current code check out on spot-check: batchSize = 10 in effects/browser/module.mjs, operations.allYieldsBetweenBatches present and passing, both main entry points where share-the-whole-runner.md says they are, and the referenced spec files exist.

The two known findings reproduce identically and remain open, neither touched by this delta: the unguarded collectTests traversal (same crash, same stack, no summary) and the browser suite still unreachable from CI (npm run website exit 0 with a failing leaf in the bundle, no consumer in .github/).

@sergey-shandar

Copy link
Copy Markdown
Contributor Author

I've also checked const batchSize = 1 and it looks very good, so responsive. Even if it slows down (almost twice) and gives incorrect measurement results twice as long, it still looks good.

Linking is an `Import` effect now and its failure is an `IoError`, which is
`{ code?, message }` — so a module-loading failure reports `stack: ''` where the
deleted runner reported the loader's own frames. Those frames are what name the
importing module and line for a broken dependency graph, and this section
already requires failures to retain a stack, so it is a regression rather than a
declared trade-off.

The task carries the fix: one additive optional field on `IoErrorInfo`. The
rationale that made `IoError` drop it — a stack does not survive a wire hop — is
right about `cause` and own properties and wrong about a stack, which is a
string.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PyLwDNkPQM1uD1Tg5ApBg
@sergey-shandar

Copy link
Copy Markdown
Contributor Author

Another issue (need to create a TODO). We now print PASS ...name of the test..., while standard runners (e.g. fjs t) use the correct approach: print the name of the test BEFORE running it, and then add Ok, Pass, Fail, whatever. It also helps if the runner crashes; we can see where.

You checked `batchSize = 1` and found it much more responsive, at almost twice
the run time. It does not have to cost that: the 2x was `setTimeout`'s 4 ms
clamp, not the yielding. Over a `MessageChannel` the same per-launch yield costs
about 3%.

So there is no batch size any more. `all` starts every effect, hands the event
loop back between one launch and the next, and awaits them all at the end.
Measured on the real suite in Chromium: 40.7 s against 39.7 s with no yielding
at all, first row at 2.7 s (against 4.0 s at slices of ten), and roughly three
times as many visible progress steps. No constant left to tune, which was the
complaint.

`todo/report-scheduling.md` is deleted — its experiment is done and its answer
is the code above. The one part that outlives it, checking the yield primitive
across browsers and whether `scheduler.yield()` is worth preferring, moves to
the cross-browser task in `browser-testing.md`.

`allStartsEveryChildBeforeAwaiting` is rewritten. It raced a 1 s wall clock,
which in a suite of 3499 concurrent proofs measures how loaded the machine is —
it failed once under coverage. It now counts turns of the event loop and records
which opener reached the gate first, so it fails rather than hangs, and cannot
flake. Checked in both directions against a serializing `all`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PyLwDNkPQM1uD1Tg5ApBg

Copy link
Copy Markdown
Contributor Author

Since you found batchSize = 1 much more responsive and were willing to pay almost 2× for it — you don't have to. The 2× was setTimeout's 4 ms clamp, not the yielding. Over a MessageChannel the same per-launch yield costs about 3%.

So there is no batch size any more (401f191). all starts every effect, hands the event loop back between one launch and the next, and awaits them all at the end. Measured on the real suite in Chromium:

run first row progress steps
no yielding 39.7 s 39.8 s 2
slices of 10 39.8 s 4.0 s 29
every launch, no clamp 40.7 s 2.7 s 91

Your responsiveness, without the slowdown, and no constant left to tune.

todo/report-scheduling.md is deleted — the experiment is done and its answer is the code. The part that outlives it (checking the yield across browsers, and scheduler.yield()) moved to the cross-browser task in browser-testing.md.

One thing worth flagging: allStartsEveryChildBeforeAwaiting, which I added an hour ago, raced a 1 s wall clock — in a suite of 3499 concurrent proofs that measures machine load, and it failed once under coverage. It now counts turns of the event loop and records which opener reached the gate first, so it fails rather than hangs and cannot flake. Verified in both directions.

npx tsc clean, fjs t 3499/3499 twice in a row, npm run cov 100%.

I'm going to stop chasing further automated-review rounds here and leave the PR to you.


Generated by Claude Code

Every runner reports a test only once it has finished, so a running test is
invisible: a slow proof looks like a hung runner, the browser page counts
completions rather than naming where it is, and — the case that matters — when a
proof takes the process down, the last line printed is the last test that
*succeeded* and the one that broke is never named.

`Reporter` has no event for it: `result` takes a `SandboxResult`, so it cannot
be called before there is one. The todo adds a start event and notes that the
easy part is the event; the real question is terminal output under concurrency,
which is the same question in both hosts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PyLwDNkPQM1uD1Tg5ApBg
@sergey-shandar

Copy link
Copy Markdown
Contributor Author

The two known findings reproduce identically and remain open, neither touched by this delta: the unguarded collectTests traversal (same crash, same stack, no summary) and the browser suite still unreachable from CI (npm run website exit 0 with a failing leaf in the bundle, no consumer in .github/).

npm run website
npm start w

works very well for me and others. It could be a problem with your setup.

Copy link
Copy Markdown
Contributor Author

todo/report-before-running.md, added in 5a74d4d.

It leads with the reason you gave, because it is the strongest one: when a proof takes the process down, the last line printed is the last test that succeeded, so the one that actually broke is never named — precisely the case where a name is worth more than a result, and the case the current design has none for. The other two are that a slow proof is indistinguishable from a hung runner, and that the page counts completions where a reader wants to know which test it is on.

Reporter has no event for it today: result takes a SandboxResult, so it cannot be called before there is one. The todo adds a start event beside it, and says the easy part is the event — the real question is terminal output under concurrency, since leaves run in parallel and a half-written line cannot stay open across another test's output. That question is identical in both hosts, which is an argument for settling it in the shared core rather than twice.

Note I have unsubscribed this session from the PR's events, so I will not see further comments here. Ping me through the session if you want anything else on it.


Generated by Claude Code

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5a74d4d654

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread fjs/emergent_testing/browser/module.mjs Outdated
*
* @type {(root: Element, sources: readonly string[], importer?: BrowserImporter) => Promise<BrowserTestReport>}
*/
export const startBrowserTestSources = (root, sources, importer = source => import(source)) => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Resolve default imports against the application root

When a caller relies on the newly optional importer and passes the relative source paths this API normally consumes (for example, ./proof.mjs), the import() expression resolves them relative to fjs/emergent_testing/browser/module.mjs, not relative to the page or application entry that called startBrowserTestSources. The generated website avoids this only by supplying its own lexical source => import(source) importer; other consumers using the advertised default will load the wrong URL and receive an infrastructure-error report. Either require the importer again or resolve default sources against the root document/application base URL.

Useful? React with 👍 / 👎.

`allYieldsBetweenLaunches` queued a `setTimeout(0)` before calling `all`
and asserted the timer had fired by the time `all` resolved. That reads
as engine-neutral and is not: bun delivers every pending `MessageChannel`
message before it runs a due timer, so the 59 yields `all` performs keep
a `setTimeout(0)` queued behind them indefinitely. Confirmed directly —
59 yields, then 100000 more, and the timer never fires under bun 1.3.11,
while node fires it on the first. The proof was calling a yielding `all`
a non-yielding one, and bun CI failed on it.

Queue a `MessageChannel` message instead, which is the same queue `all`
posts to, so the assertion states the property every engine agrees on: a
launch ends the task, so anything already queued runs before `all`
resolves. Still load-bearing — deleting the yield from `runYielding`
fails it under bun.

effects/browser/module.mjs is unchanged; the browser behaviour measured
in Chromium is what it was.

Also files a todo for browser timer precision: `performance.now()` is
coarsened to 100 us in Chromium and rounded and jittered to 1 ms in
Firefox, which is at or above what a typical proof takes, so the page's
per-proof durations are largely the clamp rather than a measurement.

Changelog: no user-visible change.

@o2alexanderfedin o2alexanderfedin left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approval still stands. Verified at 5a74d4d65: npm test 3499/3499 exit 0, tsc --noEmit exit 0 (main 75ee38397: 3470/3470).

The concurrency change is genuinely pinned, which is the part I was worried about — "start every child before awaiting any" is an ordering claim, and those are easy to assert without covering. Both mutants die: making all await each child as it goes fails allStartsEveryChildBeforeAwaiting (the fallback opens the gate instead of a later sibling), and removing the per-launch yield fails allYieldsBetweenLaunches (setTimeout(0) never fires before all resolves) — 3498/1 in each case.

Deleting report-scheduling.md is legitimate: tasks 1-3 shipped in cbc445420/401f1917c, and task 4 (the cross-browser scheduler.yield() check) is relocated to browser-testing.md:162-163 rather than dropped. report-before-running.md is unrelated new work, Status: open, and both claims I spot-checked hold — reports print only after completion (module.f.mjs:476, the format string needs duration) and Reporter has no start event, only result/summary/test (types.ts:65-69).

Both known findings reproduce unchanged and remain open: the unguarded collectTests traversal, and the browser suite still unreachable from CI.

@sergey-shandar

Copy link
Copy Markdown
Contributor Author

In general, we would avoid all of these problems if we follow the simplest workflow that already exist fjs t. Yes, it's sequential (but for a reason). Yes, we can't measure time accurately in a browser => make a todo (don't fix it). So the first goal is to reuse and share code, make it similar to existing code - and then solve other problems, if any. But you will solve it for all solutions. We need to add it to the design principles. Prefer reusing code and follow the example. @claude, what did you learn from this PR?

"Reuse code" is satisfiable while still getting the important half wrong:
share a module, then give the new context its own rules, and the result
looks unified but is two behaviours behind one name. Records that the
existing implementation is the specification for a port, that a
difference has to be justified rather than merely noticed, and that a
problem the new context reveals is fixed for the shared code or recorded
as an issue -- never worked around in one host.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6f5dd5977a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread fjs/effects/browser/module.mjs Outdated
let started = []
for (const effect of effects) {
if (started.length !== 0) { await yieldToLoop() }
started = [...started, run(effect)]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Attach rejection handlers before yielding between launches

When an early child of an all panics before the remaining children are launched, its promise remains without a rejection handler across one or more MessageChannel task boundaries because Promise.all(started) is attached only after the loop. I reproduced this with a throwing first Pure and later siblings: the outer call eventually catches the panic, but an unhandledrejection is emitted first (followed by rejectionhandled), so a browser controller can classify the page as crashed even though the adapter later publishes an infrastructure report. Attach a rejection handler to each promise as soon as it is started while preserving the eventual aggregate rejection.

AGENTS.md reference: AGENTS.md:L103-L106

Useful? React with 👍 / 👎.

@sergey-shandar
sergey-shandar added this pull request to the merge queue Aug 27, 2026
@sergey-shandar
sergey-shandar removed this pull request from the merge queue due to a manual request Aug 27, 2026
Restores every code file to main. The unified runner worked -- one
`runModuleMap`, an `effects/common` layer, a browser interpreter, 100%
coverage, green CI, 3435 proofs executed in Chromium -- but the way it
got there is not a way worth repeating, and the record of why is worth
more than the code was.

What is left is the knowledge:

- `share-browser-console-runner.md` is restored and now carries a "How to
  do this" section: `fjs t` is the specification including the things it
  does not do; sharing modules while keeping per-host rules looks like
  success and is not; the browser is sequential for a reason; a problem
  the port reveals is everyone's problem and belongs in an issue, not in
  a workaround inside the port. It also traces the six review rounds that
  a single unrequested batch-size constant produced, all the way to the
  bun failure, and ends where copying the example would have started.
- `DESIGN.md` section 4 gains "Follow the example", the general form of
  the same rule.
- Four issues the attempt surfaced stay, rewritten to describe the code
  as it is on main rather than as the branch left it:
  `hostile-proof-values.md`, `imports-promises-realms.md`,
  `report-before-running.md`, `timer-precision.md`.

No code changes: the diff against main is documentation only.
@sergey-shandar sergey-shandar changed the title emergent_testing: one proof runner for fjs t and the browser emergent_testing: keep what the shared-runner attempt taught, revert the code Aug 27, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 565f4cee73

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread DESIGN.md Outdated
Comment on lines +134 to +135
the new context — `fjs t` runs proofs one after another, and its report is
readable, attributable and reproducible because of it.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve fjs t's concurrent execution

fjs t does not run proofs one after another: runModuleMap and walk compose siblings with allOk, and the Node interpreter handles all with Promise.all(effects.map(runNodeEffect)) (fjs/effects/node/module.mjs:361). Treating sequential execution as the repository-wide specification—and repeating that premise in the sharing todo—would direct the future port to change existing behavior and can deadlock a proof that waits for a later sibling to start. Correct this factual premise before making it a design rule.

AGENTS.md reference: AGENTS.md:L47-L51

Useful? React with 👍 / 👎.

Comment on lines +76 to +79
`walk` then reads a sub-tree through `catch` and, on the `error` branch, reports
one failed result at that path instead of panicking — which is what restores
`exportedTreeThrows` / `returnedTreeThrows`, and gives `fjs t` a behaviour it
never had. `errorDetails` gets the same treatment at its one call site.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Make the proposed normalization fallback total

When errorDetails inspects a self-throwing proxy, wrapping that call in catch only produces another error containing the same hostile value; attempting to normalize that error repeats the trap, so the promised failed result still cannot be constructed. The fresh evidence in this documentation commit is that the proposed design records only “the same treatment” and never captures the trap-free terminal fallback learned from the earlier self-throwing-proxy fix. Specify a constant fallback that performs no operations on the caught value, and add that hostile case to the tasks, before this design is implemented.

AGENTS.md reference: AGENTS.md:L47-L51

Useful? React with 👍 / 👎.

…oth"

The first draft of "Follow the example" read as a demand for identical
behaviour, which is the wrong target: a browser has no stdout and a
terminal has no DOM, so different APIs and wrappers around a shared core
are the normal shape. It also understated the obligation that actually
keeps two contexts together.

Restates the principle as the cycle: share the code; adjust where the
host requires it; document every difference that remains; open an issue
for each problem the port revealed; solve each of those issues for every
context at once. Differences are allowed -- undocumented ones are not,
and a fix that lands in one context only is how the two drift apart
again while hiding the finding from the older one.

`share-browser-console-runner.md` follows the same wording, and its
constraints now say that host APIs may differ freely, behaviour only for
a written reason, and a fix for either runner lands in both.

@o2alexanderfedin o2alexanderfedin left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Measured at 6f5dd5977. npm test 3499/3499 exit 0, tsc --noEmit exit 0 (main 75ee38397: 3470/3470). The yield assertion is sound — removing the per-launch yield is KILLED with "all resolved without yielding to the event loop".

One thing to fix before merge: DESIGN.md:134 states a falsehood about the code it is justifying. It says fjs t "runs proofs one after another", but the node interpreter's all is async (...effects) => ok(await Promise.all(effects.map(runNodeEffect))) at effects/node/module.mjs:317, and effects/common/module.f.mjs:150-151 says so outright: "Every effect still runs… all performs them concurrently." A normative document that mis-describes the behaviour it reasons from is worse than one that omits it — the rest of the "Follow the example" section is normative rather than factual and is unaffected, so dropping or correcting that one sentence is enough.

For the record, on the queue question I raised: swapping the assertion's MessageChannel for setTimeout survives here, but that is expected and the commit says so — the setTimeout spelling only breaks under bun, and this machine has node only. Not a defect, just a limit on what I could verify.

timer-precision.md is correctly Status: open with everything unchecked, and both claims I spot-checked hold (sandbox's before/after performance.now() reads at effects/common/module.mjs:62-78, monotonic now at effects/browser/module.mjs:137).

Both long-standing findings reproduce unchanged: the unguarded collectTests traversal, and npm run website exiting 0 with a failing leaf — browser-prepare.mjs only scans text and never executes a proof, and .github/workflows/ references neither website nor browser-prepare.

"Share the code" left open where the sharing stops, which is the question
that actually decides whether two runners stay one. States it: what is
shared is the skeleton -- the control flow, the order of operations, the
decisions and their names. Every context runs that same skeleton, and
differs only by supplying a different part at a place the skeleton names.

That leaves exactly two ways to accommodate a context, both additive:
change that context's part, or improve the skeleton so every context
benefits. There is no third. A branch inside the skeleton asking which
host it is running on is a fork wearing a shared name, and a need no
existing part can express is a missing extension point rather than a
special case.

`share-browser-console-runner.md` names the runner's skeleton and its
parts in those terms -- execute a leaf, report a result, link a module --
and gains a task to check that nothing host-specific is left outside one
of them.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b9b273c723

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +87 to +90
The rule that follows: **land the shared skeleton with behaviour unchanged, then
take each new problem as its own change — in the skeleton where it belongs
there, so both runners get it, or in every part at once.** An improvement the
browser could have is an issue, not something to introduce inside a port. A

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve browser defenses while sharing the runner

Fresh evidence in this documentation commit is the explicit ordering that lands the shared skeleton before addressing newly exposed problems. When the skeleton copies fjs t, this removes behavior the current browser runner already provides: hostile proof-tree reads become attributed failures there, and cross-realm promises are subscribed through the intrinsic then, while fjs t can crash or silently pass them. Following this sequence therefore introduces a browser regression between changes; require existing behavior from either runner to be preserved during the sharing change rather than deferred.

AGENTS.md reference: AGENTS.md:L103-L106

Useful? React with 👍 / 👎.

Comment on lines +17 to +20
**A value that resists being read is not attributed to the test that produced
it.** Two functions in the shared core read user-supplied values without a
guard: the `collectTests` traversal enumerates a returned proof tree, and
`errorDetails` reads `message`/`stack` and calls `String` on a thrown value. A

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Point the guard at the CLI normalization sites

For hostile values thrown under fjs t, this problem statement identifies errorDetails as an unguarded shared-core reader, but at this commit that helper is browser-only and already guards both property access and String conversion (browser.mjs:24-57). The actual CLI coercions are the unguarded String(v) and template interpolation in defaultReporter.result (module.f.mjs:402-412), so following the proposed tasks leaves the CLI panic path untouched. Rewrite the design around those reporter normalization sites.

AGENTS.md reference: AGENTS.md:L47-L51

Useful? React with 👍 / 👎.

## Investigate imports, promises and realms

**Priority:** P3
**Status:** open — investigation, not yet actionable

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use a canonical todo status

The repository's todo format restricts Status to exact values such as open, but this value appends an explanation and therefore no longer matches any allowed status. Keep the field as **Status:** open and move “investigation, not yet actionable” into the issue body so status searches and tooling can classify it consistently.

AGENTS.md reference: AGENTS.md:L55-L56

Useful? React with 👍 / 👎.

@sergey-shandar
sergey-shandar added this pull request to the merge queue Aug 27, 2026
Merged via the queue into main with commit 12106a3 Aug 27, 2026
19 checks passed
@sergey-shandar
sergey-shandar deleted the claude/todo-unification-runners-7wvd1q branch August 27, 2026 11:04
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.

3 participants