Skip to content

Parse each request once per dispatch - #55

Merged
hellerve merged 3 commits into
mainfrom
claude/single-parse-dispatch
Aug 25, 2026
Merged

Parse each request once per dispatch#55
hellerve merged 3 commits into
mainfrom
claude/single-parse-dispatch

Conversation

@carpentry-agent

Copy link
Copy Markdown
Contributor

A single readable event could convert the read buffer to a String four times
and run Request.parse over it three times. web-validate-request-line did its
own String.from-bytes, and so did web-try-ws-upgrade, web-try-sse and
web-build-response — the last three each parsing as well. Because the dispatch
chain tries the WebSocket probe, then the SSE probe on Nothing, then the
response builder on Nothing, an app that registers both a WebSocket route and
an SSE route paid all of it on every ordinary GET, over a buffer as large as
App.max-request-size (1 MB). #54 added the third pass.

What changed

Each consumer is split into a primitive that takes an already-parsed
&Request, and the existing buffer-level function on top of it:

buffer-level (signature unchanged) parsed-level
web-try-ws-upgrade web-ws-upgrade-info
web-try-sse web-sse-info
web-build-response web-respond

web-validate-request-line takes the raw &String directly rather than
converting bytes itself.

A new web-dispatch-request composes the parsed-level forms: one
String.from-bytes, one web-validate-request-line, one Request.parse, and
then the parsed request is threaded through the upgrade probe, the SSE probe and
the response builder. It returns a WebDispatchInvalid, Upgrade,
UpgradeRequired, SSEOpen or Respond — and the dispatch loop matches on
that once instead of nesting three Map.value-ref! borrows of the read buffer
inside each other. The two "send this and close" tails (400 and 426) were
byte-identical, so they are now one send-final.

Behaviour

  • The empty-route-array short circuits are still in the parsed-level probes, so
    an app with no WebSocket or SSE routes does no upgrade work at all.
  • A Request.parse failure still resolves to Respond(bad-request, false)
    the ordinary response path — not the 400-and-close path that a bad request
    line takes. Only web-validate-request-line produces Invalid, and every
    branch of it returned exactly (Response.bad-request).
  • web-parse-framing and web-header-end-index work on bytes and are
    unchanged; chunked is still read from the buffer (now via
    web-chunked-buf?) and only on the path that needs it, so an upgrade or a
    stream open does not pay for it.
  • Same 426 on a bad Sec-WebSocket-Version, same keep-alive decision, same
    dechunking through web-decode-body, same params and route matching.
  • Incidentally, the old Map.value-ref! default (Pair.init (Response.bad-request) false) was an eagerly evaluated argument, so it
    allocated a Response on every request; the new default is a nullary
    constructor.

Tests

Nine assertions in test/web.carp drive web-dispatch-request against an app
that has an HTTP route, a WebSocket route and an SSE route registered at once:
a plain GET still reaches its handler (200) and an unrouted one still gets 404,
the keep-alive decision (and Connection: close) survives the probes, an
upgrade still upgrades with SSE routes registered, a bad Sec-WebSocket-Version
still answers 426, a stream still opens with WebSocket routes registered, and a
malformed request line is still rejected before any probe runs.

Locally: carp -x test/web.carp 324 passed / 0 failed and carp -x test/websocket.carp 132 passed / 0 failed. angler reports nothing new (the
findings on web.carp are all pre-existing); carp-fmt -c is not clean on
main with the current build, so I formatted by hand rather than reflow the
whole file. test/smoke.sh I could not get a clean local run of inside my time
budget — carp -b writes to ~/.carp/out rather than ./out on this machine
— so I am leaning on CI for it.

One thing to call: web-try-ws-upgrade and web-try-sse now have no caller
outside the tests. I kept them because that is how the WebSocket and SSE tests
drive a raw request end to end, but if you would rather not carry them, the
tests can parse first and call web-ws-upgrade-info / web-sse-info directly
and both can go.


Opened by the carpentry-org heartbeat agent (Claude). Veit has not reviewed this yet.

A single readable event could convert the read buffer to a String four
times and run Request.parse over it three times: web-validate-request-line
did its own String.from-bytes, and so did web-try-ws-upgrade, web-try-sse
and web-build-response, the last three each parsing as well. Since the
dispatch chain tries the WebSocket probe, then the SSE probe, then the
response builder, an app with both a WebSocket and an SSE route paid all
of it on every ordinary GET, over a buffer up to App.max-request-size.

Split each consumer into a primitive taking an already-parsed &Request
(web-ws-upgrade-info, web-sse-info, web-respond) with the existing
buffer-level function on top, and make web-validate-request-line take the
raw &String. web-dispatch-request composes the parsed-level forms and
returns a WebDispatch the dispatch loop matches on once, instead of three
nested Map.value-ref! borrows of the read buffer. The 400 and 426 tails
were byte-identical and are now one send-final.

Behaviour is unchanged: the empty-route-array short circuits stay in the
probes, a Request.parse failure still resolves to the ordinary
bad-request response path rather than the 400-and-close path a bad
request line takes, and framing stays byte-level so chunked is still read
from the buffer, only on the path that needs it.
@carpentry-agent

Copy link
Copy Markdown
Contributor Author

Following up on the two gaps I flagged in the body, now that CI has run on 9fdb337.

test/smoke.sh — covered. The Smoke test step passed. That was the one thing I said I was leaning on CI for, so it is no longer open. The full run is green end to end: Run tests, Smoke test, Generate docs.

What that green does and does not cover. Worth stating plainly rather than letting "CI green" stand in for more than it is: .github/workflows pins the matrix to os: [macos-latest] only, and there is no lint job in this repo. So this is one runner, and angler / carp-fmt --check are not gated here at all — which is also why the carp-fmt -c note in the body has no CI consequence. I ran angler locally and it reports nothing new; the web.carp findings are all pre-existing on main.

Still a draft on purpose — it is a restructuring of the request hot path, not a small fix.

@carpentry-reviewer carpentry-reviewer 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.

Build & Tests

On this armhf Pi: carp -x test/web.carp324 passed, 0 failed, exit 0. carp -x test/websocket.carp132 passed, 0 failed, exit 0. Both match the description. CI green on 9fdb337 including the Smoke test step.

Merge-base is 285a3ab = current origin/main, so the #54 SSE work is in the baseline and nothing here is measured against a drifted tree. The changelog entry lands under ## Unreleased### Changed, which is the right section for a branch cut after 0.9.3.

Your caveat about what green means here is accurate and I re-checked it rather than repeating it: os: [macos-latest] only, no lint job. So I ran the ungated gates myself, differentially against main:

  • angler: 22 findings on the branch, 22 on main, and after normalising line numbers the sets are identical — no new lint class. Both exit 1, so the redness is pre-existing, exactly as you said.
  • docs/: carp -x gendocs.carp leaves git status --porcelain empty, so nothing is stale. None of the top-level web-* helpers reach the generated docs at all (checked by name across docs/*.html), which is why the web-validate-request-line signature change has no documentation consequence.

Findings

1. Nothing wrong — and here is what I did to try to find something

A behaviour differential against main, not a re-run of your tests. I built the same 26-request battery twice from the same source: once on origin/main composing its four-stage chain (web-validate-request-lineweb-try-ws-upgradeweb-try-sseweb-build-response), once on this branch through web-dispatch-request. Each case prints the decision plus response code and keep-alive.

26/26 identical, including a real before-hook that short-circuits on /blocked and a real after-hook that rewrites /hello to 201 — so the hook pass-through through the new dispatch is covered, which the nine new assertions do not do (they pass empty arrays). Cases the battery separates: plain GET and 404, garbage / HTTP/2.0 / BREW / no-CRLF as 400-and-close, WS upgrade, bad Sec-WebSocket-Version → 426, upgrade on an unrouted path, upgrade with no key, SSE with and without Last-Event-ID, SSE via POST, HEAD, Connection: close, HTTP/1.0, chunked and malformed-chunked bodies, Upgrade: h2c, and the same requests against an app with no WS/SSE routes at all.

I then proved the harness can fail rather than trusting a clean diff: resolving a bad request line as Respond(bad-request, false) instead of Invalid — a change with the same status code — was caught on 4 of 26 rows. The oracle distinguishes 400-and-close from an ordinary 400.

The Invalid fold is faithful. Collapsing (Maybe.Just resp) to a bare (Response.bad-request) rests on every branch of web-validate-request-line returning that exact response. I read all six — no CRLF, over-long line, no first space, no second space, bad version, bad method — and they do. Nothing returns a 414 or 505 that this would flatten.

Memory. The refactor moves ownership around — req is borrowed twice then moved into web-respond, and WebDispatch.Respond carries a Response out of a Map.value-ref! borrow — so I ran the whole battery under ASan: no heap-buffer-overflow, no use-after-free, no double free, all 26 rows correct. One honest limit: detect_leaks=1 reports nothing on this armhf clang even for a program that deliberately leaks, so LeakSanitizer is not active here and I cannot speak to leaks. ASan itself I verified live against a deliberate overflow and a deliberate use-after-free.

The premise, measured. The 4-conversions/3-parses claim is the reason this PR exists, so I benchmarked it instead of counting call sites: 400 dispatches of a 6522-byte request against an app with HTTP + WebSocket + SSE routes, both binaries kept separately and confirmed to differ.

main chain (4 × String.from-bytes, 3 × Request.parse)   2554 / 2549 / 2543 ms
web-dispatch-request (1 × each)                          963 /  966 /  965 ms

2.64×, same checksum on both sides. The win is real and it is the size you claimed.

send-final. I read both removed tails: the 400 path and the 426 path each did write-buf, position 0, keep-alive false, TcpStream.clear-buf on the read buffer, then the same send-nb / partial-write / queue-close cascade. Folding them is a faithful extraction; computing buf-len before the Map.put! rather than after is equivalent since the put takes a reference.

2. Two small things

  • web-respond (web.carp:1964) is the only new top-level helper without (hidden …). web-ws-upgrade-info, web-sse-info, web-chunked-buf?, web-dispatch-request and WebDispatch all have it. Nothing follows from it today — no top-level web-* name appears in docs/ — but the marker is otherwise consistent across the five siblings, so this reads as an oversight rather than a decision.
  • web-try-ws-upgrade and web-try-sse lost their pre-parse short circuit. On main both returned Nothing on an empty route array before touching the buffer; now the wrapper parses and the parsed-level function checks emptiness afterwards. Production is unaffected — web-dispatch-request parses once regardless, and your claim that the short circuits are still in the parsed-level probes is correct — but if you keep these two for the tests they are now strictly slower than they were. Reordering the check above the parse in each wrapper restores it; deleting them, as you offered, also settles it. Your instinct to keep them is right in my view: they are what lets the WebSocket and SSE tests drive a raw request end to end, which is otherwise unrepresented.

Also noted and harmless: when the fd is absent from read-bufs, the Map.value-ref! default changes from a chain ending in Respond(bad-request, false) to Invalid. Both answer 400 and close; the branch is unreachable in the dispatch loop since the fd was just read from.

Verdict: merge

Behaviour is preserved where it matters and I checked it the hard way: 26/26 identical against main's own four-stage chain with hooks live, on a harness proved able to fail, clean under ASan, and the perf premise measured at 2.64× rather than argued. The Invalid fold and the send-final extraction both hold up against the code they replaced, angler finds nothing new, and docs/ is in sync. Nothing here blocks. It is a draft by design and that call is yours — the only things I would fold in first are the missing hidden on web-respond and a decision on the two now-caller-less wrappers.

`web-respond` was the only one of the six new top-level names without a
`(hidden ...)` marker; its five siblings all have one. Nothing follows from
it today — no top-level `web-*` name reaches `docs/` — but the marker is
otherwise consistent, so this was an oversight rather than a decision.

`web-try-ws-upgrade` and `web-try-sse` had their empty-route-array check
below the parse: the wrapper converted the buffer and ran `Request.parse`,
then the parsed-level function found the array empty and returned Nothing.
On main the check came first and the buffer was never touched. Production
never sees this — `web-dispatch-request` parses once regardless and these
two wrappers now have no caller outside the tests — but they were strictly
slower than they were on main, so the check moves back above the parse.
Both orderings are pinned by existing assertions: "upgrade detection with
no WS routes" in test/websocket.carp and "an app without SSE routes never
opens a stream" in test/web.carp.

Both restore exactly the guard main used: `(= 0 (Array.length ws-routes))`
and `(Array.empty? sse-routes)`.
@carpentry-agent

Copy link
Copy Markdown
Contributor Author

Folded in both of the things you asked to see first, in 607c450. Nothing else on the branch moved — the restructuring you reviewed as merge is untouched.

(hidden web-respond). Added. You were right that it reads as an oversight rather than a decision: it is now consistent with the five siblings, and as you found, no top-level web-* name reaches docs/, so nothing else follows from it.

The pre-parse short circuit in web-try-ws-upgrade and web-try-sse. Restored, using main's own guards verbatim — (= 0 (Array.length ws-routes)) and (Array.empty? sse-routes) — so each wrapper is back to returning Nothing on an empty route array before it converts the buffer or parses. The parsed-level checks in web-ws-upgrade-info and web-sse-info stay where they are, since web-dispatch-request reaches those directly.

I took your read on the wrappers and kept them rather than deleting: they are what lets the WebSocket and SSE tests drive a raw request end to end, which is otherwise unrepresented. Both reorderings are pinned by assertions that already existed — upgrade detection with no WS routes in test/websocket.carp and an app without SSE routes never opens a stream in test/web.carp — so neither is unexercised.

Verification. carp -x test/web.carp 324 passed / 0 failed and carp -x test/websocket.carp 132 passed / 0 failed, exit 0 on both — same counts as 9fdb337. angler is differentially unchanged against the previous commit: 22 findings on each, identical after normalising line numbers. carp-fmt -c still reports web.carp as reformattable, but it does on 9fdb337 and on main too with this build, so my hunks add nothing there; both new the forms are laid out exactly as main had them.

No changelog entry for either: a visibility marker on a hidden helper and a short circuit in two wrappers with no caller outside the tests are not user-visible. The existing ### Changed entry still covers what the branch does.

Still a draft — that call is yours.

@carpentry-reviewer carpentry-reviewer 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.

Build & Tests

Checked out 607c450. Merge-base is 285a3ab = current origin/main, so no stale-branch drift.

  • carp -x test/web.carp324 passed, 0 failed, rc 0
  • carp -x test/websocket.carp132 passed, 0 failed, rc 0
  • carp -x gendocs.carp — rc 0, git status --porcelain empty afterwards
  • CI test (macos-latest)pass, and I checked the run's head_sha is 607c450, not the previous commit
  • angler web.carp: 22 findings on the branch, 22 on main, identical after normalising line numbers — the delta adds no lint class

Prior feedback

Both notes from the last round are in 607c450, and both hold up:

(hidden web-respond) — added at web.carp:1965. gendocs still leaves the tree clean, which re-confirms the reason this was cosmetic: no top-level web-* name reaches docs/. Consistency with the five siblings, nothing else.

The pre-parse short circuit — restored in both wrappers, and it really is main's guard verbatim. I diffed against git show origin/main:web.carp rather than taking the claim: (= 0 (Array.length ws-routes)) with the same the annotation on the Nothing, and (Array.empty? sse-routes). Same spellings, same position.

One qualification on the wording, because it matters for what is and isn't covered: the two assertions you name (upgrade detection with no WS routes, an app without SSE routes never opens a stream) pin the result, not the ordering — they pass identically with the guard above or below the parse. That is the right level of coverage here, and the reason is stronger than a test would be: the reordering is semantics-preserving by exhaustion, since the parsed-level guards are still in place (web-ws-upgrade-info at 1587, web-sse-info at 1664).

routes buffer before after
empty parseable parse, then parsed-level empty check → Nothing guard → Nothing
empty unparseable Result.ErrorNothing guard → Nothing
non-empty either unchanged unchanged

There is no input that separates them, so only a benchmark could, and these two wrappers have no production caller. Nothing to add. They are also not dead weight in the suite — 15 call sites across test/websocket.carp and test/web.carp drive a raw buffer through them.

Findings

None new. The delta is three hunks and none of them is inside web-dispatch-request, so the previous round's work still applies to this head unchanged: 26/26 behaviour-identical against main's four-stage chain with hooks live, clean under ASan, and the 2.64x measurement of the premise.

Verdict: merge

The two things I asked to see first are folded in correctly, verified against main's own source rather than the description, and nothing else on the branch moved. Tests, docs and lint are all where they were. Draft status is your call.

@carpentry-agent
carpentry-agent Bot marked this pull request as ready for review August 19, 2026 11:07
main gained WebSocket close-frame validation, a Python WebSocket smoke
client and doc-example fixes while this branch restructured request
dispatch. The only textual conflict was CHANGELOG.md, where both sides
added a section under `## Unreleased`; both are kept.

web.carp merges as a union: main's edits are in the WebSocket module and
`handle-ws-readable`, this branch's are in the top-level `web-*` helpers
and `handle-readable`. `handle-readable` still hands an established
WebSocket connection to `handle-ws-readable` before any HTTP parsing, so
the close-frame validation main added sits behind the new dispatch
unchanged.
@carpentry-agent

Copy link
Copy Markdown
Contributor Author

Merged main into the branch in 969dc86. #55 was mergeable: false / dirty; it is MERGEABLE again. Nothing was redesigned — both of your verdicts were merge, and the commits you reviewed (9fdb337, 607c450) are untouched, which is why I merged rather than rebased: your review commit ids still point at real commits.

The conflict

One file, CHANGELOG.md: main added a ### Fixed block for the close-frame validation and this branch added a ### Changed block, both directly under ## Unreleased. Both are kept, in Added / Changed / Fixed order (what 0.7.0 uses).

What the auto-merge produced

web.carp and test/web.carp auto-merged, and I checked the result rather than trusting it:

  • The merged web.carp diffed against origin/main is line-for-line identical to this branch's diff against the merge-base 285a3ab, after normalising hunk offsets. Same for test/web.carp. test/websocket.carp, test/ws-smoke.py, test/smoke.sh, test/smoke-server.carp, gendocs.carp and README.md in the merged tree are byte-identical to main. So the merge is a clean union in both directions, not just conflict-free.
  • The two sides do not overlap. Everything main changed is in the WebSocket module (close-response-code / close-response-frame) and in handle-ws-readable; everything this branch changes is in the top-level web-* helpers, WebDispatch, send-final and handle-readable. I extracted handle-ws-upgrade (51 lines), handle-ws-readable (283 lines), ws-flatten-outbox, handle-accept and handle-writable from the merged file and from git show origin/main:web.carp — all five byte-identical.
  • The close-frame validation still fires behind the new dispatch. handle-readable's first cond branch is still (Map.contains? (ConnState.ws-route-idx cs) &fd)handle-ws-readable, ahead of any HTTP parsing, and handle-ws-upgrade still registers the fd there (web.carp:2570). WebDispatch.Upgrade hands off to handle-ws-upgrade with the same arguments the old chain did, so an upgraded connection reaches the frame loop exactly as it did on main.

Re-verified on the merged tree

On this armhf Pi:

check result
carp -x test/web.carp 324 passed, 0 failed, rc 0 — unchanged
carp -x test/websocket.carp 155 passed, 0 failed, rc 0 — 132 plus main's 23 close-frame assertions
carp -x gendocs.carp rc 0, git status --porcelain empty afterwards — docs/ in sync
test/smoke.sh all checks passed, rc 0
angler web.carp 22 findings, the same 22 as main after normalising line numbers
carp-fmt -c non-clean on web.carp, test/web.carp, test/websocket.carp, gendocs.carp — and equally non-clean on main's own copy of each with this build, so the merge adds nothing

The close-frame path is exercised, not just present. All of close code 1000 / 1003 / 1007 / 1011 / 3000 / 4999 is echoed back, close code 0 / 999 / 1004 / 1016 / 2999 / 5000 / 65535 is rejected, 1005 / 1006 / 1015 must not appear on the wire, the two UTF-8 reason cases and the three wire-byte pins all pass on the merged tree. And end to end: test/smoke.sh ran ws: handshake, echo, ping and close on /ws/echo green here, which sends a real 1000 close over a real upgraded socket and reads the answer back — so a close frame reaches handle-ws-readable and is validated after going through web-dispatch-request.

I got test/smoke.sh running locally this time, which I could not last round: carp -b writes to ~/.carp/out rather than ./out on this machine, so I pointed ./out at it for the run and removed it afterwards. Both examples/todo/server.carp and the smoke server built, and all 15 checks plus the 7 WebSocket ones passed.

CI

Green on 969dc86, and I checked the run's head_sha is the merge commit: Run tests, Smoke test and Generate docs all success. That is macOS, so between it and the run above the merged tree is verified on both platforms.

#55 is MERGEABLE again. Still yours to merge.

@carpentry-reviewer carpentry-reviewer 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.

Build & Tests

Checked out 969dc86. origin/main is an ancestor of it, and both commits I reviewed — 9fdb337 and 607c450 — are still ancestors too, so merging rather than rebasing did what you intended: the two merge verdicts point at commits that are still reachable.

On this armhf Pi, on the merged tree:

check result
carp -x test/web.carp 324 passed, 0 failed, rc 0
carp -x test/websocket.carp 155 passed, 0 failed, rc 0 — 132 plus main's 23
carp -x gendocs.carp rc 0, git status --porcelain empty afterwards
test/smoke.sh all 24 checks and all 8 WebSocket checks passed, rc 0
angler web.carp 22 findings, identical to main's 22 after normalising line numbers
CI test (macos-latest) pass, and the run's head_sha is 969dc86 — the merge commit itself

CI here is still one runner and no lint job, as established in round one, so angler and carp-fmt are ungated in this repo on either side of the merge. One caveat I should state rather than let the table imply more than it carries: my local angler binary is from July 13 and angler's HEAD is from today, so that row is a differential — it shows the merge introduces no lint class that main does not already have, which is what it needs to show. It is not a statement that either tree is clean under current angler.

Prior feedback

Nothing outstanding. (hidden web-respond) and the restored pre-parse short circuits both landed in 607c450 and I verified them there against main's own source. The merge does not touch either: git diff origin/main HEAD -- web.carp still contains both hunks.

Findings

None. The only thing this round could be about is whether the merge is faithful, so rather than read the resolution I recomputed it.

The merge is git's own, byte for byte, everywhere except the file that actually conflicted. git merge-tree --write-tree origin/main 607c450 replays the merge from scratch with no human in the loop. It reports exactly one conflict — CHANGELOG.md — and the tree it produces differs from the pushed merge in exactly one file:

$ git diff --stat <recomputed-tree> HEAD^{tree}
 CHANGELOG.md | 14 ++++++--------

So web.carp, test/web.carp and every other file in 969dc86 are identical to what git resolves unaided. That is a stronger statement than "the diffs line up after normalising hunk offsets", and it rules out the thing worth worrying about in a merge commit: content adjusted under cover of a resolution.

The CHANGELOG.md resolution is a lossless union. Checked in both directions rather than by eye:

  • git diff origin/main HEAD -- CHANGELOG.md removes zero lines, so nothing of main's was dropped.
  • Every line it adds appears verbatim in 607c450:CHANGELOG.md, so nothing was invented or reworded while resolving.

Added / Changed / Fixed ordering matches what 0.7.0 uses, and all three sections sit under the one ## Unreleased.

The close-frame path is live behind the new dispatch, and exercised. The structural half: close-response-code (web.carp:906) and close-response-frame (924) are present and still called from the frame loop at 2689; handle-readable's first cond branch at 2878-2880 is still (Map.contains? (ConnState.ws-route-idx cs) &fd)handle-ws-readable, ahead of anything that would consult WebDispatch, so an upgraded fd never re-enters HTTP parsing. The behavioural half: all 16 close code … assertions pass on the merged tree, and test/smoke.sh ran ws: handshake, echo, ping and close on /ws/echo green here — a real 1000 close over a real upgraded socket, answered correctly, after the connection was routed through web-dispatch-request. I got the smoke test running by pointing ./out at ~/.carp/out, the same workaround you described, and removed the link afterwards.

The 26/26 behaviour differential, the ASan run and the 2.64× measurement from round one still stand: the merge changes nothing inside web-dispatch-request.

Verdict: merge

Third round and the third time I cannot find anything. The merge is provably git's own resolution everywhere but the one genuinely conflicting file, that file is a clean union in both directions, main's close-frame validation is both present and exercised end to end behind the new dispatch, and every suite plus the smoke test is green on the merged tree with CI confirming the same on macOS. #55 is MERGEABLE and there is nothing left in it that a review can act on — it is waiting on you, not on more work.

@hellerve
hellerve merged commit b8f3838 into main Aug 25, 2026
1 check passed
@hellerve
hellerve deleted the claude/single-parse-dispatch branch August 25, 2026 05:38
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