Skip to content

Batched dispatch: one worker walks a bounded run of activities, and the server holds the bound - #424

Merged
m2ux merged 51 commits into
mainfrom
feat/batched-dispatch
Aug 5, 2026
Merged

Batched dispatch: one worker walks a bounded run of activities, and the server holds the bound#424
m2ux merged 51 commits into
mainfrom
feat/batched-dispatch

Conversation

@m2ux

@m2ux m2ux commented Aug 3, 2026

Copy link
Copy Markdown
Owner

Closes #407. Spins two pre-existing defects out to #425 and #429 rather than fixing them here.

Summary

Every activity of a workflow is handed to a freshly spawned worker, and every one of those workers rebuilds its context from nothing — system prompt, project instructions, tool schemas — before it reads a line of workflow content. This change lets one dispatch carry a run of several activities: the worker walks them in a single context, pausing at each activity boundary for the orchestrator's commit and at each gate for the user's answer, and being continued in place rather than replaced.

This is not a return to the walk-everything-in-one-context idea that was reverted last year. That failed because it had no limit at all. The point of this change is the limit: a batch is bounded, the server enforces the bound at the moment it hands content over, and the bound is small.

The definition half — the client activity loop and the continuation operation — is #420 against the workflows branch, bumped in here.

What bounds a batch

A batch is not declared anywhere. It is the run of activities one delivery scope takes delivery of, which the session history already records. So the server sees a batch with no orchestrator cooperation, needs no new state and no schema migration, and a worker that omits a parameter does not escape it. Two limits apply:

Limit Derivation Default
Cumulative delivered characters context_tokens × BATCH_HEADROOM_FRACTION × BUNDLE_CHARS_PER_TOKEN fraction 0.35
Distinct activities BATCH_MAX_ACTIVITIES 3

The character budget carries a headroom fraction of its own because the eager-bundling fraction answers a different question — how much of one activity's window may go to inlined step techniques — and at 0.80 the same arithmetic admits thirteen of the main workflow's fifteen activities into one context. The activity cap covers what a character count is blind to: the context establishment the server never delivers, the code the worker reads, the artifacts it drafts, and degradation across a long walk. Those are what actually overflowed the agent last time.

Enforcement sits on the freshly loaded session ahead of every composition step, so a refusal costs nothing — the payload is never assembled — and the refusal event is written with no await between the read and the write.

Three carve-outs, each load-bearing. A context that has taken no activity is always admitted its first: lazy reads draw down the same budget, so a scope that read past it before taking any activity would otherwise be refused the work it was spawned to do. An activity the scope already holds is always served: that is a worker resuming after a gate asking for the payload it is sitting on, and thirteen of the main workflow's fifteen activities carry a gate, so refusing it would end every batch before its second activity. And the session's own agent is unbounded: a scope equal to session.agentId is the context that owns the whole walk by construction, which is what contextMode: "persistent" describes. Its run is the session, not a batch. That second one is the only interpretive decision the issue did not settle — the alternative, bounding every multi-activity scope, is stricter but retires persistent solo sessions as a side effect.

What makes a failed resume cheap

The worker reports each activity as it completes, so the session pointer always tracks where the walk actually is. A failed resume then costs one activity: a replacement worker picks up the current activity, takes full delivery, and re-crosses already-answered gates silently, because checkpoint responses are keyed by activity with no agent component. Deferred reporting would leave the pointer stale, redo the whole batch, and hand the replacement a pointer that disagrees with what it finds — which stops it.

Cost keeps a figure per activity

record_usage records one row per activity a dispatch covered, sharing an agent_id, rather than one figure per dispatch attributed to whichever activity the orchestrator happens to name. Without that resolution there is no way to work out a safe batch size from real runs, which is the whole plan for the settings.

Measuring it

npm run bench:batch walks a run twice — a fresh context per activity, then one context for the whole run — and reports the difference. tests/e2e/batch-duration-smoke.test.ts asserts the shape of it, so a regression that costs a batch its saving fails in CI rather than in a run profile weeks later. Its floor sits at 20% against a measured 27.8%, and the benchmark counts deliveries with the server's own rule rather than a second copy of it. Over the analysis run through the middle of the main workflow, at a 200,000-token window:

per-activity batched
Contexts the server met 3 1
Characters delivered 223,157 161,027
Activity payloads, in walk order 76,902 / 85,316 / 60,939 76,902 / 62,222 / 21,903
Server-side elapsed, best of 3 590 ms 589 ms

Server-side elapsed is a wash, and the tooling says so rather than claiming a speed-up. Reference delivery composes every payload in full and then hashes it to decide what may collapse, so a batch does slightly more server work to put fewer bytes on the wire. The millisecond figures above are one machine's and do not reproduce: best-of-3 has landed anywhere from 1% against the batch to 18% for it across runs, warm and cold, while the character figures are bit-identical every run. The smoke test's assertion is that batching is not materially slower, not that it is faster.

The run duration a batch saves is the contexts it avoids — two, here. Nothing headless can observe a harness spawning an agent, so the script prices those from a measured per-dispatch spawn cost supplied as a flag — default 87 seconds, the mean of the four setup-walk dispatches of the profiled 27 July run, which ran 77, 65, 42 and 165. That gives 174 seconds, reported as a projection with its input named and never added to the measured figures.

Delivery collapse is 27.8% on this walk against the 32% the investigation record cites for the same run. Both are right about different things: the record reads a real run's ledger, where the worker's lazy technique and resource fetches collapse too, and this script issues activity deliveries only. 27.8% is the floor.

The figure rose during review, and the reason is worth stating. It was 24.7% until the role technique every worker stub says to apply — activity-worker — was actually added to what a client worker is delivered. That is some 5,700 characters more on every activity, and a larger fixed floor is a larger thing to collapse: the first activity pays it once, the second and third get it as an unchanged marker. A saving that grows when the payload grows is the mechanism working, not a measurement drifting.

Which limit binds, and why the calibration moved twice

The starting fraction of 0.20 came from the investigation record's average. The first benchmark run refused the analysis run at its third activity, so it was re-based to 0.35 against measured figures. A review sweep then found those figures inflated by a double count, and the arithmetic was redone.

A third pass, over the sealed session records rather than the benchmark, found the remaining problem: bench:batch never fetches a technique or resource lazily, so its 161,027 characters for three activities is the eager floor, not what a batch accumulates. Across 112 real worker contexts, one activity costs a median 74,109 characters once its lazy fetches are counted — 90th percentile 182,642, maximum 261,827. The lazy half is usually the larger one, and the calibration had been reading only the smaller.

So the claim that "the cap does the routine work and the budget catches unusually heavy runs" was the wrong way round for the main workflow. Both cases are wanted, and both are now stated in src/config.ts and the docs:

  • On the main workflow the budget binds first — two real runs reach it after two activities. That is the mechanism working: three heavy activities would put over half a declared 200,000-token window into workflow content before a line of code is read.
  • On the setup sequence the cap binds first, its activities costing 33,000 to 154,000. That sequence is batching's first user, and a character budget alone would admit more of it than a context should hold.
  • A smaller declared window is bounded proportionally, and where the third activity is refused depends on what the first two cost. On the median activity the budget binds before the cap below roughly 106,000 declared tokens; on the 90th percentile, below roughly 261,000 — so on heavy content the budget is the binding limit at any window worth declaring. The lighter run the benchmark walks puts the crossover near 99,000.

Admission is checked before a delivery, so the admitted activity can carry a batch one heavy activity past the budget — up to 261,827 characters on measured content. Refusing after composing would pay the composition and still not un-deliver it. That ceiling is now documented rather than left to be discovered.

Existing sessions, and rolling back

Every session.json in the repo was walked — 67 files, 124 state nodes including nested children — and every delivery scope run against this branch's own predicate. No session on disk would be refused. All 112 worker scopes sit at exactly one distinct activity; the only two scopes above that are the session's own agent, which is exempt. Identities were reused 35 times, but always for the same activity, which is precisely what the already-taken carve-out serves.

Two notes the change now carries:

  • The exemption follows a field callers set. dispatch_child defaults agent_id to "worker" and a resume rebinds session.agentId to the resuming caller's identity, so a dispatched worker passing the session's own identity is unbounded, and which context holds the exemption can move across a resume. Minting one identity per dispatch — already required by the corpus — is what keeps it where it belongs.
  • Rolling back after a refusal needs care. batch_refused is a new event in a strictly-validated enum, and load validates the whole file including nested children. An earlier server reading a session that recorded a refusal fails that validation and reports SEAL_MISMATCH — the error normally read as tampering or a rotated key. Forward compatibility is unaffected.

Malformed and legacy state is walked across 33 shapes in tests/batch-bound-totality.test.ts — a history that is absent, null, or not a list at all; entries that are null, a string, a number; a missing data envelope; absent agentId, absent chars, unknown event types; migration output. Each function returns a number, an array or a verdict, and none throws.

That claim was in this body before the test was, which is the defect AP-144 reference-without-provenance names — added on this branch, and fired on this branch's own record. Writing the carrier found two real gaps. A history that is not a list is not iterable, and the ?? [] fallback covers the field being absent but not its being an object or a number, so the loop threw where the intent was plainly to see no batch. And a size only had to be a number, which admits NaN — it compares false against the budget from both directions, so one poisoned event would have read as past budget for the rest of the session and refused every later activity, naming a character count of NaN.

The test's own risk was being vacuous, and its first draft was: every shape varies one well-formed fixture, and that fixture was written from memory with the fields one level above where the readers look, so all 33 cases passed having exercised nothing. It now asserts the baseline fixture is read before asserting anything about the variations.

What the review sweep found on this side

  • The budget double-counted eagerly bundled content. An activity_dispatched size is the whole get_activity response, bundled techniques and resources included; their own observability events were then added on top. Measured at +48% on one activity and +70% across the run, which made a nominal 280,000-character budget bind at 164,540 — the budget firing where the design says the cap should. Counted once now, with only lazy fetches adding to an activity payload. The benchmark counted the same way and is corrected with it.
  • A technique fetch spent an activity slot. An out-of-band context announces itself on its first server call of any kind, and that dispatch event carries no payload size because no activity was delivered. The slot was spent anyway, so a context that had taken two activities could be refused a third under a message stating it had taken three.
  • The refusal saved a pre-composition snapshot, which the success path a few lines below explicitly reloads to avoid — a concurrent checkpoint response or sibling delivery would have been silently reverted. The check now runs on the freshly loaded state, which is both race-free and cheaper.
  • BATCH_MAX_ACTIVITIES=0 became 3. Zero is how an operator says "no batching"; a positive-only reader rejected it and handed back the default — the loosest setting, the opposite of the request, silently. Both settings are now clamped to the nearest end of their range, where 1 activity is batching switched off.
  • Refusals were recorded per retry, so the tally the settings are revised from counted how insistent a worker was. Recorded once per scope, activity and limit now.
  • The refusal message told the orchestrator to dispatch afresh without saying the replacement needs a NEW identity — the bound is keyed on the identity, so reuse is refused forever and a genuinely fresh context under a used identity receives markers for content it does not hold.
  • may_continue is answered before the delivered activity's own lazy fetches, which draw down the same budget, so a batch reported as having room can still be refused at the next boundary. The delivered and budget counts on the same response are what a reader compares to see how close it came, the limitation is stated where it matters, and the corpus handles the refusal by ending the batch and spawning a replacement rather than by predicting it more precisely.
  • The bound is not adversarially tight, since the delivery scope is the caller's own unauthenticated agent_id. The prose claiming it "cannot be talked past" is corrected to what is true: a worker that omits a parameter does not escape it.

What a second review sweep found

A claim-verification pass and a mutation pass over the change-set. The mutation pass scored 35 killed / 17 survived; every survivor named below is now dead, and the tree was left clean.

One real defect. The failed-continuation recovery advanced the session pointer twice for the same activity — once in the continuation, once in the dispatch that followed it — and the second advance records that activity exited and complete before a worker has walked a step of it. Measured on a live session, the activity came back both current and completed, and resume, status polling and manifest validation all read that. The continuation now owns getting a worker onto what it advanced to, so dispatch-activity is only reached where no advance has happened yet.

One contract with no carrier. The corpus told the orchestrator to act on the worker reporting a refusal, and the corpus defines no envelope that can say so. It now keys on an envelope that is not one of the two tagged results, which the existing partial-result rule already covers.

The bound was expressed twice. The refusal and may_continue are one comparison asked from opposite sides, and they must agree at the boundary — a batch sitting exactly on its budget is admitted, so it must also be told to continue. Flipping either operator passed the whole suite. They now share one predicate, and a test asserts the complement across the boundary — off the carve-outs, since an activity the scope already holds is served whatever the reading says.

The benchmark's accounting was unreconciled. It counts deliveries with its own copy of the server's rule, which is how the same double count got into both at once. Injecting it into the benchmark alone moved the headline saving by eight points with the suite green. It now counts with the server's own deliveredChars as its only figure, so no second implementation is left for a test to reconcile.

Figures that were wrong. The double count inflates a run of three by 70%, not 32%. The eager fraction admits thirteen of fifteen activities, not nine. The spawn cost is 87 seconds — the mean of 77, 65, 42 and 165 — where 41 was a token count misread as a duration; the projection is 174 seconds, not 82. Each was stated in two or three places, and they now agree.

Test gaps closed. Budget boundary from both sides, which limit is named when both bind, refusal dedupe keyed on the context as well as the activity and limit, the budget floor, the reported headroom, and the continuation gate's requirement that the envelope be a completed activity.

Known and stated rather than fixed. compose-prompt now takes the delivery mode as a declared input rather than leaving a continuation indistinguishable from a fresh spawn. The loop-gate test models one bag per iteration and so checks gate wiring rather than sequence — tests/batch-loop-walk.test.ts is the half that walks iterations. A batch surviving a real checkpoint onto its next activity is covered end to end by tests/e2e/batched-dispatch.test.ts.

Two review classes now have canon and a guard

Review turned up two defect classes with no home in the catalogue, so nothing stopped either recurring. Both additions are appended entries — no new category, no index to update.

A reference that names no sourceAP-144 reference-without-provenance. Its test: for each prose reference to a value, a tool result or a completed action, name the construct that supplies it, and flag when the answer is nothing. Passive construction is the written tell. A new entry rather than a widening of anchored-protocol-references, whose Detect opens on a reference to something declared — a target wearing the wrong form is a different fault from a target that does not exist.

Prose delivered before the framework existsPrinciple 33 Pre-Session Prose Stands Alone and AP-145. discover returns the bootstrap procedure raw, before any session, so there is no get_resource and no get_activity and nothing can be fetched. The text must be executable from itself, with a canonical name allowed only as a label for later.

And a guard behind the stance, because a stance nothing checks is the shape of constraint the canon's own principle 9 rejects. check:bootstrap refuses, on that one surface, a link into the corpus and a rule address — each in every spelling the corpus sanctions: an inline destination, one wearing a title, an angle-bracket destination, a reference definition and a raw HTML attribute for the first; the qualified pair, the full ancestry path and the shortened bare name for the second. It also reports a fence left open, since fence state is what separates a shown link from an instruction, and refuses a file gutted below the length a procedure needs. 22 guards at that point. The link check strips code spans, which disposes of the backticked AGENTS.md that would otherwise read as a missing corpus file; the rule-address check cannot, since the construct it hunts is itself backticked, so it discriminates by looking the pair up in the corpus — the left half alone is no discriminator, because forty-five technique names are a single common word — plan, context, record, test among them — so plan.json would read as an address. Probed against both defects and against session.json / repo.git / AGENTS.md / an MCP URI / a group::operation label: each defect fires alone, the controls stay silent, and a missing target file exits unmeasured rather than green.

The client loop is walked, not just gated

tests/batch-loop-walk.test.ts reads the loop body out of the definition, evaluates the real gates against a live bag, applies each step's effect and records what fired in which iteration. The gate test could not see what makes this loop hard — worker_result is rewritten mid-iteration — and both faults that reached review were that shape.

Reconstructed as mutations, both fail it, as does moving the commit past the advance. Dropping a clause from a gate does not, because the loop usually exits before the missing clause matters, and that is the gate test's half; each file catches what the other cannot. Writing it corrected the invariant: the pointer advances once per activity, not per iteration — an iteration that only answers a gate must not advance, because the worker is still on the activity it holds.

The step effects are that file's reading of the operations rather than something the server enforces, since an agent executes the loop. They are written as a table to be audited for that reason.

A child is told where its workflow starts

A fresh session has no current activity, so the server accepts exactly one id on that session's first next_activity — the workflow's own first activity — and refuses every other one. The parent knows its own workflow's first activity, not its child's, and dispatch_child returned the child's session index, planning folder and workflow id and version without it. A caller could only guess, or read the answer off the rejection.

The meta corpus was reading it off the rejection. Its loop primed the session pointer from a bare initialActivity, which is a field on each workflow's own definition rather than anything in scope there — so the literal word went out as the activity id, came back refused, and the run recovered because the refusal message names the right id. It cost a round trip and a validation error in the trace on every run, and stayed invisible because the error carried its own remedy. Under the bound this PR adds, that round trip is also a delivery charged to the meta agent.

dispatch_child already resolves the child workflow in order to create the session, so the value is in hand exactly where the child is made. It now travels in the response's workflow block beside the id and version it belongs with. The corpus side is in #420: create-session declares it as an output, initialize-session binds it, and the dispatch loop primes from the braced reference. The loop walk pins that reference, so the two cannot drift.

A guard for what a set action does

Nothing inspected an action: set value. Of the three guards that parse a set at all, one reads its target as a producer, one its description, one its target — none looks at what it writes. So a value naming a variable without braces was invisible to all twenty-two, and an unbraced name is the literal string.

That is the fault this branch spent two rounds on: the dispatch loop primed its pointer from a bare word for as long as the loop existed, the word reached the server as an activity id, and the lookup failed on every run. The two spellings are one character apart and read identically to a person, which is what makes a machine the right reader.

The rule is not new — variable-binding already separates a rename from a literal. check:set-values makes it checkable: a value shaped like a bag name (letters, digits, underscores, optionally dotted) has to be braced. Shape is the discriminator, because a literal here is a boolean, a number, null, an empty collection, or hyphenated prose, and none of those matches. Its second check is that a set has a target at all, which is where three corpus findings came from. Both are hard zero with no baseline: every set action and every checkpoint setVariable in the corpus already satisfies them — 98 and 224 respectively, of which 49 are the bare enum words that made the shape line necessary, which is why this is the moment to hold the line rather than inherit a debt list.

A guard for the adapters reached through a variable's value

A harness kind becomes a file and an operation kind becomes a Rules section inside it, so the adapters are reached entirely through the values of two variables. No binding check sees them, and the obligation that each adapter exposes the same slices lived in one prose sentence beside a map that calls itself authoritative — while a second enumeration in the loader has to agree with it. Measured before the guard existed: a partial adapter, a renamed slice, a map row with no file, and a deleted slice all passed the whole suite.

check:harness-set proves the three enumerations describe one set, in both directions, with no orphan adapter file and no mapped file missing. It lands green on a clean baseline — four rows, four files, three slices, twelve of twelve present — so every future failure is real.

Its own first draft passed five mutations of the corpus it was written against, and the worst is instructive: the guard read the operation-kind vocabulary from wherever the file first named those words, which is an ## Outputs section 350 bytes above the step that actually decides them. Narrowing the deciding step changed nothing the guard saw. The test fixture missed it because the fixture had no ## Outputs at all, so it could not reproduce the shape of the file it was standing in for. The guard now reads the step by heading, reports a name it cannot parse rather than dropping it from both sides, ignores a heading shown inside a fence, names a slice declared twice, and distinguishes an aliased adapter from an unregistered one.

Writing it also surfaced a defect on the dispatch path: activity-worker, the role every client worker stub says to apply, was declared only by the meta workflow, so for a client workflow it was named and never delivered. That is a fixed cost of about 5,700 characters an activity now being paid, and it is why the benchmark figures above rose.

One reader for markdown destinations and fences

Three guards each needed the same two answers — which lines are illustration, and where does this line point — and each had grown its own, narrower than the spec. scripts/markdown-refs.ts now owns both, and the bootstrap and anchor guards read from it.

Rewiring the anchor guard found a link it had never been able to see: its fence tracking counted markers, so the first nested closer inside a template wrapper inverted the phase and took the rest of the file out of the scan. Two corpus templates carry that defect, and between them exposed eight headings inside their examples as real anchor targets — a link to one would resolve against the corpus and break in the reader's hands. Both wrappers now run to four backticks.

The fail-safe turned out not to be a property of the matcher at all. A caller hunting destinations is safer reading more lines; a caller collecting anchor targets is safer reading fewer, because an anchor it invents makes a broken link resolve. Sharing one direction handed the anchor guard the wrong one, so the direction is now the caller's to state, and an unclosed fence is a finding rather than a silent guess.

The site-link guard reads any attribute spelling HTML permits, walking pairs in order so a quoted value cannot be mined for markup.

Scope of change

  • src/utils/batch.ts (new) — the bound as a derived predicate over history.
  • src/utils/dispatch.ts — the prompt a dispatched worker is composed with.
  • src/tools/resource-tools.tsdispatch_child reports the child workflow's first activity.
  • scripts/check-set-action-values.ts (new, npm run check:set-values) with tests/set-action-values.test.ts — every set action, and every checkpoint setVariable, names where it writes and braces what it reads.
  • scripts/markdown-refs.ts (new) with tests/markdown-refs.test.ts — destinations and fences read in one place, with scripts/check-resource-anchors.ts and scripts/check-site-links.ts rewired onto it. The anchor guard also reports a fence left open, and takes the opposite unclosed-fence fail-safe from the link scan: collecting anchor targets is safer reading fewer lines, because an anchor it invents makes a broken link resolve.
  • scripts/check-harness-adapter-set.ts (new, npm run check:harness-set) with tests/harness-adapter-set.test.ts — the harness kinds, the adapter files and the loader's core-ops list describe one set.
  • src/loaders/core-ops.tsactivity-worker is delivered to the workers whose stubs name it.
  • tests/batch-bound-totality.test.ts (new) — the bound answers over any history it is handed, and src/utils/batch.ts no longer throws on a history that is not a list or counts a NaN size as a measurement.
  • package.json — the three new guard scripts.
  • AGENTS.md — how to inspect a backticked construct when the shell refuses a literal one.
  • scripts/generate-site-data.ts — the tool reference records what dispatch_child returns and what a re-dispatch does.
  • tests/variable-seeding.test.ts — that the child's first activity, not the parent's, is the one reported.
  • scripts/check-bootstrap-self-contained.ts (new, npm run check:bootstrap), registered in scripts/guards.ts, with tests/bootstrap-self-contained.test.ts — the pre-session surface keeps no reference the reader cannot follow, in any spelling the corpus sanctions.
  • src/tools/workflow-tools.ts — enforcement in get_activity, _meta.batch, and per-activity usage in record_usage and projectUsage.
  • src/config.ts — the two settings, their clamping, and the calibration behind the defaults.
  • src/schema/state.schema.ts — the batch_refused event.
  • scripts/run-batch-benchmark.ts (new, npm run bench:batch) — the measurement.
  • tests/batch-bound.test.ts, tests/batch-loop-gates.test.ts, tests/batch-loop-walk.test.ts, tests/e2e/batched-dispatch.test.ts, tests/e2e/batch-duration-smoke.test.ts (all new) plus tests/config.test.ts. The loop-gate test reads the client loop's when: gates out of the corpus and evaluates them, so a copy cannot drift from the definition.
  • docs/dispatch_model.md, docs/resource_resolution_model.md, site/design/protocol.html, and descriptions across the tool schemas, src/utils/delivery.ts and src/utils/session/params.ts that still said a worker handled exactly one activity.
  • Submodule bumps for workflows (carrying Batched dispatch: one worker carries a bounded run of activities #420) and engineering (carrying the implementation record), with the corpus baseline stamp following the workflows commit. Generated schema and site artifacts regenerated.

Validation

  • npm run typecheck — clean.
  • npm run check:all — 24 guards, all pass, none unmeasured.
  • npm run test:ci — 975 pass, 14 skipped, 0 fail.
  • Both new guards re-probed against every defect and control this body claims for them: the bootstrap guard fires on a corpus link in all five sanctioned spellings and on a rule address in all three, stays silent on session.json, repo.git, AGENTS.md, an MCP URI, a group::operation label, an external URL and a plain code span, and reads a gutted file as unmeasured rather than clean. The set-value guard fires on an unbraced bag name, a dotted path and a camelCase name, stays silent on hyphenated prose, a braced reference, a boolean and a file name, and reads an empty tree as unmeasured. 9 of 9 each.

Acceptance criteria

  • One dispatch can carry a run of activities; the worker walks them in a single context, reports each as it completes, and resumes in place after a gate or a commit boundary.
  • The server refuses the next activity in a batch once accumulated delivery passes a budget carrying its own headroom setting, and no batch exceeds three activities.
  • A failed resume costs one activity, not the batch: a replacement worker picks up the session's current activity and re-crosses already-answered gates without prompting.
  • Every activity boundary inside a batch still commits and pushes before transitions are evaluated.
  • Cost is recorded per activity covered by a dispatch, and mid-batch arrivals are not counted as fresh dispatches — the scope-only discriminator landed in Delivery identity: a resumed worker keeps the identity it was dispatched with, and second copies are counted #411 and is now covered by a batch walk.
  • The client dispatch loop runs in the orchestrator rather than a spawned worker. Not in this change — see below.
  • A re-measurement against the July baselines shows the setup walk's saving, and the batch-size settings are revised from that data. After merge, over real runs; the headless benchmark and the smoke test are what can be asserted before then.

What is deliberately left out

Resuming a run still rebuilds its workflow instead of continuing it. A second dispatch into an occupied planning folder writes a fresh child over the one already there, and hands back the same identifier, so the run restarts at the first activity having reported the work it was resuming. A fix landed here and was taken back out: two review rounds found five faults in it, each visible only once the previous was fixed, and that is a design still finding its shape rather than a batching change. It is #429, with the traces and the criteria both rounds produced. The bug predates this branch.

The client dispatch loop still runs in a spawned worker. Meta binds the activity-worker technique to every one of its activities, so the activity holding the client loop executes inside a spawned agent — which the harness rule against inheriting the dispatch primitive says holds no dispatch primitive. The defect is real and predates this work.

Fixing it means the meta orchestrator executes that activity itself, and no construct in the corpus says that: activity audience is not declarable, and an orchestrator reading its own activity body runs into the rule keeping orchestrators away from activity bodies. That is a new schema construct plus a carve-out in a load-bearing rule — a design call worth its own change, and separable from batching, which works within the existing topology. It is filed as #425, which carries the survey behind it.

Non-goals

  • No return to one agent walking a whole workflow. The bound is the point, and it is applied where content is handed over rather than in instruction text.
  • No cost model. Batch sizing starts conservative and is calibrated from measurement; there is no static estimate of a workflow's context load.
  • The content weight of what a delivery carries stays with the delivery-cost epic ([Epic] Delivery Cost: What a Delivery Costs to Build and to Send #404); this changes how many worker contexts a walk needs, not what each delivery contains.
  • Naming batches in the main workflow. Its natural candidates are the analysis runs in its middle, chosen after measurement.

Investigation detail

.engineering/artifacts/planning/2026-08-03-batched-dispatch-implementation — what was built, the decisions taken while building it, the measured numbers, and the review sweep in full. The measurements it builds on are in the batched-dispatch investigation record.

🤖 Generated with Claude Code

m2ux added 5 commits August 3, 2026 13:12
The batch is the run of activities one delivery scope takes, read off the
session history, so the server sees one with no orchestrator cooperation and a
worker cannot leave the bound behind by omitting a parameter. Two limits hold
it: a cumulative character budget over everything delivered to the scope, under
a headroom fraction of its own, and a hard cap on distinct activities that
covers what a character count cannot see.

The get_activity response reports where a context stands against its bound, so
the ordinary end of a batch is the worker stopping; the refusal is the backstop,
and it records a batch_refused event so the limit each run met is countable.

Usage is a figure per activity a dispatch covered, which is the resolution a
batch size is calibrated from.
The walk takes the analysis run through the middle of the main workflow under
one delivery scope: the second and third activities collapse against what the
context holds, three deliveries read as one dispatch and two arrivals, the
fourth is refused with nothing delivered, and the activity the context already
holds is still served so a batch survives its gates. A narrow declared window
puts the budget ahead of the cap, and the session's own agent walks past both.

Unit coverage states the arithmetic exactly: which activities count as a batch,
that a marker draws down nothing, that a redelivery is not charged twice, and
which limit a given history meets first.

The dispatch model documents the bound, the two carve-outs, and why a failed
resume costs one activity.
bench:batch walks a run twice — a fresh context per activity, then one context
for the whole run — and reports contexts met, characters delivered and
server-side elapsed for each. A smoke test asserts the shape so a regression
that costs the batch its saving fails here rather than in a run profile weeks
later.

Server-side elapsed is a wash, and the tooling says so rather than claiming a
speed-up: reference delivery composes every payload in full and then hashes it
to decide what may collapse, so a batch does slightly more server work to put
fewer bytes on the wire. The run duration a batch saves is the contexts it
avoids, priced from a measured per-dispatch spawn cost the caller supplies and
reported apart from the measured figures.

The headroom fraction is set from the benchmark's own numbers. The analysis run
delivers 263,253 characters into one context, 224,073 by the end of its second
activity, so 0.20 of a 200,000-token window refuses the batch the measurements
name as the best candidate. At 0.35 the run is admitted and the activity cap
closes it, which is the intended relationship between the two limits.

Submodule bumps for workflows, carrying the corpus mechanism, and engineering,
carrying the implementation record, with the corpus baseline stamp following the
workflows commit.
Three faults a review sweep of the loop found, and the test that would have
caught them.

The continuation reached its gate in the same iteration that created the worker,
because the dispatch it followed had already bound the identity and returned an
activity_complete result. It would have continued that worker onto the activity
it had just finished, ahead of the commit for it. The continuation now comes
first in the loop body, so its gate reads an identity carried INTO the iteration
rather than one minted within it.

A terminal activity that left the batch room kept its worker identity, and
nothing downstream releases it: the loop exits on a null activity, so a live
worker was left with nothing to continue it, and a re-entry from end-workflow
would have skipped the dispatch and continued on a stale result. The release now
also fires when there is no next activity.

The continuation declared a step_manifest input bound from a variable the meta
bag does not carry. The manifest travels the way dispatch-activity's does, named
in the protocol step that calls next_activity.

The loop-gate test reads the gates out of the definition and evaluates them
against the bag states a walk reaches, so a copy cannot drift from the YAML and
pass while the definition breaks.

The refusal is now taken on the freshly loaded state, ahead of every composition
await. A refusal costs no composition, and its event is written with no await
between the load and the save, where the earlier placement could have reverted a
concurrent write.
Two accounting faults a review sweep found, both in the direction of a bound
that bites earlier than it says it does.

An activity payload is the whole delivery response, so the techniques and
resources it bundles eagerly are already inside it. Their own observability
events were added on top, charging the same bytes twice: +48% on one activity of
the main workflow, +32% across the analysis run, which made a nominal
280,000-character budget bind at roughly 165,000. Counted once, only what a
worker goes back for lazily adds to an activity payload. The benchmark counted
the same way and is corrected with it, so the recalibration reads honest numbers:
the analysis run delivers 154,699 characters into one context and collapses
24.4%, not 11.9%.

A dispatch event with no payload size is a context announcing itself on a
technique or resource fetch. It was spending an activity slot, so a context that
had taken two activities could be refused a third under a message stating it had
taken three.

The batch bound now clamps out-of-range settings to the nearest end rather than
falling back. An operator writing zero activities means no batching, and a
positive-only reader handed back the default of three — the loosest setting, the
opposite of the request, silently.

A refusal is recorded once per scope, activity and limit, so the tally the
settings are revised from counts how often a limit bound rather than how often a
worker retried. The refusal now also says the replacement needs a new identity,
which is the one thing that has to change and the thing it omitted.

The delivery response reports remaining headroom beside the boolean, because the
boolean is answered before the lazy fetches of the activity being delivered draw
that headroom down. Descriptions of a worker that handled exactly one activity
are retired across the tool schemas, the delivery model and the protocol page.
m2ux added 24 commits August 3, 2026 15:06
A mutation pass over the change-set found the boundary unguarded from both
sides. The refusal admitted a batch sitting exactly on its budget and the
delivery response told it to continue, which is the intended pairing — but the
two were separate expressions of one comparison, so flipping either operator
passed the whole suite. They now share withinBatchBound, and a test asserts the
complement across the boundary rather than either side of it.

The benchmark counts deliveries with its own copy of the same rule. A second
implementation nobody reconciles is how one double count got into both at once,
so the benchmark now also reports the figure computed by the server's own
counting, and the smoke test requires them equal. Injecting that double count
into the benchmark alone used to move the headline saving by eight points with
the suite green.

Boundary, precedence, dedupe scope and the budget floor gain the cases that make
them fail when broken, and the headroom the response reports is asserted as the
budget less what has been delivered.

Corrections to figures that were wrong or inconsistent between the places they
appeared: the double count inflates a run of three by 70%, not 32%, binding a
280,000-character budget at 164,540; the eager fraction of 0.80 admits thirteen
of fifteen activities, not nine; and the per-dispatch spawn cost the projection
prices contexts at is 87 seconds, the mean of the four measured setup dispatches
of 77, 65, 42 and 165, where 41 was a token count read as a duration. The
benchmark's collapse field says what it actually observes, which is the lazy
fetches rather than the run's saving.
… one

The hop the mechanism turns on had no end-to-end cover: a worker takes an
activity, stops at a gate, the gate is answered, it resumes on the activity it
holds, and is then advanced to the next activity of its batch. Thirteen of the
main workflow's fifteen activities carry a gate, so a batch that cannot survive
one never reaches a second activity. The walk asserts one dispatch and two
arrivals under one identity, a batch that grew from one activity to two across
the gate, collapse on the second, and nothing recorded as delivered twice.

The second walk is what makes a failed resume cheap: a replacement under a new
identity takes the current activity whole — the one activity it costs — and is
waved through the gate the dead worker already answered rather than asking the
user again. The replay was covered by reading the key it uses and by nothing
else.

Removing the already-taken carve-out, or keying the arrival discriminator back on
the activity, each fail the first walk.
The benchmark measures activity payloads and never fetches a technique or a
resource lazily, so its figure is the eager floor rather than what a batch
accumulates. Read off 112 worker contexts in the sealed session records, one
activity costs a median 74,109 characters once its lazy fetches are counted, with
a 90th percentile of 182,642 and a maximum of 261,827 — the lazy half is usually
the larger one, and the calibration had been reading only the smaller.

So the claim that the cap does the routine work and the budget catches unusually
heavy runs was the wrong way round for the main workflow, where two real runs
reach the budget after two activities. Both cases are wanted and both are now
stated: the budget binds on heavy activities, the cap binds on the setup
sequence whose activities cost 33,000 to 154,000, and a smaller declared window
is bounded proportionally. Admission is checked before a delivery, so the
admitted activity can carry a batch one heavy activity past the budget, which is
stated rather than left to be discovered.

Two operational notes the change had no home for: the exemption for the session's
own agent follows a field callers set — dispatch_child defaults it to worker and a
resume rebinds it — and a session that has recorded a refusal cannot be read by an
earlier server, which reports itself as a seal mismatch rather than as a schema
problem.

may-continue reads an empty batch the way the refusal does. A context that
announced itself on a technique fetch and then read enough lazily to pass the
budget was told to stop before it had taken an activity at all, while the refusal
would still have served it.

The benchmark counts deliveries with the server's own counting rather than a
second copy of the rule reconciled by a test. The reconciliation passing was the
evidence that one implementation suffices.
The context-establishment sentence had five, the two-limits argument three, the
double-count measurement three — and the copies had already drifted, one place
saying the eager fraction admits nine of fifteen activities where the others said
thirteen. The dispatch model keeps them; the code points at it.

Blocks that failed the repo's own comment-proportionality rule are cut to their
why: nine lines of doc over a one-line comparison, four inline lines over two
lines of arithmetic, and a smoke test whose docstring paraphrased the script it
imports.

Kept: the note on why the refusal is taken before any composition await, which is
a live invariant a reorder would break, and the benchmark header, which is a
command's help text and the home for the spawn-cost provenance.
The exemption for the session's own agent had three homes and the history was
walked four times a delivery. batchState is the one reading, and the refusal and
the answer handed back on a delivery are both taken from it.

The response drops remaining_chars, which no rule read and which was the budget
less the delivered count on the same object. The two counts stay, so a reader can
still see which limit closed a run and how close the other came. The limit type is
inline on the refusal that is its only user.

A context that has taken no activity is left alone by both sides whatever it read
lazily — the shape an out-of-band technique fetch produces, which one real session
already sits in, and which the merged reading now covers by construction rather
than by two separate early returns.
The gate test evaluates each gate against one bag, which cannot see what makes
this loop hard: worker_result is rewritten mid-iteration, so a gate that reads
correctly against a frozen bag can still fire at the wrong moment. Both faults
that reached review were that shape.

This reads the loop body out of the definition, evaluates the real gates against a
live bag, applies each step's effect, and records what fired in which iteration.
Scenarios are the envelopes the worker-producing steps return: a clean batch, a
gate on either activity, two gates on one activity, a spent batch, a terminal
activity with room left.

Reconstructing the two faults as mutations, both fail it, as does moving the commit
past the advance. Dropping a clause from a gate does not — the loop usually exits
before the missing clause matters — and that is the gate test's half. Each file
catches what the other cannot, which is stated where a reader will need it.

Writing it corrected the invariant: the pointer advances once per ACTIVITY, not
once per iteration. An iteration that only answers a gate must not advance, because
the worker is still on the activity it holds.

The step effects are this file's reading of the operations, not something the
server enforces, since an agent executes the loop. They are a table for that reason.
Principle 33 is the stance and AP-145 the instance; this is the structure behind
them, because a stance nothing checks is the shape of constraint the canon's own
principle 9 rejects.

The guard refuses two constructs on the one surface delivered before a session: a
markdown link into the corpus, and a rule address whose left half names a real
technique. Both were in that file until this branch, so it is preventive rather
than remedial.

The link check strips code spans, which disposes of the backticked AGENTS.md that
would otherwise read as a missing corpus file. The rule-address check cannot — the
construct it hunts is itself backticked — so it discriminates by corpus lookup
instead: a dotted pair is an address only when its left half is a technique that
exists. That keeps session.json, repo.git and AGENTS.md out and lets the real thing
through.

An operation name in group::operation form stays legitimate, being a label for the
home a rule keeps once the bundle arrives rather than something to follow now, so
neither check looks at it. Probed against both defects and against those controls:
each fires on its own, and the controls stay silent. A missing target file exits
unmeasured rather than green, since absent and clean would otherwise look identical.
A mutation pass found the only two live behaviours nothing defended. The refusal
message's requirement that the replacement carry a NEW identity — the one thing
that has to change, and the thing an earlier draft omitted — is now asserted. And
the refusal's placement, which no behavioural test can see because both placements
give the same verdict and only the wrong one loses a concurrent write, is asserted
against the source: no await between the load and the save. Both fail when
reintroduced.

The benchmark figure moved when two corpus bumps landed after it was written. Every
full activity payload rose 469 characters while the collapsed ones did not, so the
run now delivers 155,168 rather than 155,060. Everything derived from it holds: the
saving still rounds to 24.7%, the budget is unchanged, and the crossover stays near
95,000 declared tokens.

The routine-work reasoning that was retired from config and the docs survived as
fact in a test comment. The refusal message kept a claim about re-crossing an
answered gate that the corpus dropped for having no home, on a path where no gate
has been answered. The establishment-to-collapse ratio is restated from the figures
counted once per response rather than the span the measurement record disowns.

The site's activity-delivery page describes the batch envelope, which it had no
generated region to pick up.
…n frame

Nine faults in the guard and the simulator, both written this session, both
reviewed for the first time.

The guard could stop detecting silently. Fence state was a toggle, so an odd
marker count left every later line looking fenced and took the link check out of
service on a green verdict — the one failure this guard must not have. An
unbalanced count is now a finding of its own, and while the markers are unbalanced
every line is read, so an unclosed fence cannot hide a link either way.

Its rule-address discriminator keyed on the left half alone, and around thirty
techniques carry a single common word as a name, so plan.json, context.yaml and
test.each all read as addresses. It now looks up the PAIR: a dotted reference is
an address only where the corpus declares that rule on that technique. Link
targets are excluded from that scan, since a path inside one is a path.

The scheme allowlist admitted no punctuation, so it flagged the one URI the
guard's own header blesses. RFC 3986 schemes and same-document anchors both pass
now.

Its test asserted a hard zero and nothing else, which would pass just as well if
detection broke. Synthetic roots now prove each check fires, and prove the
constructs the real text carries stay silent. It also reads the corpus this run
measures rather than the built-in default, so a worktree checks its own tree.

The walk restated the loop's exit test and primed the pointer itself, so nine
mutations to everything outside the body — the condition's operator, its variable,
the whole block, the iteration ceiling, both pre-loop steps, the activity's own
transition — passed every test and every guard. It now evaluates the declared
condition, walks to the declared ceiling, and asserts the frame a batch of any
length needs. All nine fail.

The two-gate scenario was satisfied by a one-gate walk, since it only inspected
the first iteration. It now pins the iteration count against a one-gate walk and
asserts the second gate moves no pointer. And three EFFECTS entries are named as
declaring Outputs no gate reads, rather than described as having no effect.
…rule spellings

Three lenses over the material the last round created — the guard, the walk, and
the prose — because that material had never been reviewed. The guard and the walk
were the last round's own fixes.

The walk's exit test was the serious one. It read the loop's declared condition,
which was the point of the last change, but then compared with both sides coalesced
to null. The server's evaluateCondition compares strictly. So deleting the single
line "value: null" from the loop's condition left all sixteen tests and all
twenty-two guards green, while the real evaluator reads null as unequal to
undefined and the loop never exits: two hundred iterations, two hundred commits,
on a pointer already null at the second. Coalescing turned a runaway into a clean
stop. The same optimism sat in three more places, each inverting a real failure
mode into a benign one — an unprimed pointer modelled as zero iterations rather
than two hundred dispatches of nothing, and an absent ceiling modelled as a loop
that never runs when the schema means unbounded. The walk now delegates to the
server's own evaluator and parses the condition through validateCondition, so a
malformed one fails by naming the field.

Two more mutations escaped it. Retargeting advance-activity's set, or hardcoding
its value, left the pointer immobile and every test passing — the one write that
closes the loop, in the test whose own comment claims to pin that agreement. And
the frame test fixed array positions rather than a frame, so a third pre-loop step
that nulls the pointer, a step after the loop that re-primes it, and a second loop
the walk's find cannot see all passed. The top-level step list is now pinned
whole. Nine mutations, all caught; the walk's own runaway cap is also named apart
from the declared ceiling, since a truncated walk was reading as a completed one.

The guard knew one spelling of a rule address out of three. dotted-rule-address
sanctions the bare name for an inherited rule, so the regression the guard exists
to catch escaped by writing the address the house style prefers — measured safe to
close: 473 declared rule names against nine backticked tokens in the real text,
zero colliding. A full ancestry address escaped too, because a single scan consumes
workflow.technique and never tests the pair that matters; that three-segment form
appears five times in the corpus. Every adjacent pair is now tested. Ordinary
markdown escaped as well: a destination wearing a title, and a reference
definition, are both links the check could not see.

Its fence tracking counted markers, so a three-backtick example nested in a
four-backtick wrapper inverted the phase and reported illustration. Closes now
match their opener's character and length and carry no info string, which is what
CommonMark says. An unclosed fence still forces every line to be read, since that
failure must not be silent. A workflow's own TECHNIQUE.md was keyed on the literal
string TECHNIQUE, producing thirty-three pairs no reference can ever write and
leaving that whole tier unaddressable. An emptied file read clean, because presence
was proven and content was not.

Its test asserted a hard zero over synthetic roots that exercised one of the four
keying branches. Six behaviours were unpinned, including the Rules-section gating
that keeps 940 Inputs headings from becoming rule names. Fourteen neuterings now
fail, where six survived.

The prose findings are smaller. Both homes said two carve-outs where three are
load-bearing; the third — a context that has taken no activity is always admitted
its first — is tested and was named nowhere. The proportional-window threshold was
derived from the benchmark's lighter run while the bullets above it used the
measured distribution, so it now gives the crossover on the median and the ninetieth
percentile and says which run gives 95,000.

One finding is left deliberately. prime-initial-activity sets the pointer from a
bare initialActivity, the only unbraced variable reference in the corpus — every
other one is braced, so this reads as the literal string rather than the client
workflow's field. It predates this branch, and correcting it needs either a bound
variable or a doWhile loop that leans on the orchestrator's declared fallback. The
walk pins the current spelling so that change cannot pass unnoticed.
Markdown permits raw HTML, and the link check read only the bracket-paren form.
So writing the same corpus link as an anchor passed with exit 0 — the guard's
whole subject, spelled a way the reader follows just as easily. Destinations now
come from href and src attributes too, and an HTML destination is stripped before
the rule-address scan for the same reason a bracket-paren one is: a path inside a
link is a path.

One over-report is left deliberate and now says so. A link in a four-space
indented block reports even though CommonMark renders it as code, because telling
an indented code block from a list item's continuation needs a real parser. Over-
reporting is the safe direction on this surface, and fencing the illustration
quiets it.
A fresh session has no current activity, so validateActivityTransition accepts one
id on a session's first next_activity — the workflow's own initialActivity — and
refuses everything else. The parent knows its own workflow's first activity, not
its child's, and dispatch_child returned the child's session_index, planning folder
and workflow id/version without it. So a caller either guessed or learned the
answer from the rejection. The meta corpus was doing the latter, once per run.

dispatch_child already resolves the child workflow to create the session, so the
value is in hand where the child is made. It now travels in the workflow block of
the response beside the id and version it belongs with, and the tool description
says what the field is for.

The corpus side of this is on the workflows branch: create-session declares it as
an output, initialize-session binds it, and the dispatch loop primes its pointer
from it. The walk's frame test pins the braced reference so the two cannot drift.
The note on indentation claimed fencing an illustration would quiet the check.
That holds only within three spaces of the margin. The bound is absolute where
CommonMark reads indentation relative to the containing block, so a fence nested
one level deep in a list is not taken as a fence and the links it shows report.
The guarded file already nests to five spaces, so this is reachable rather than
theoretical.

The bound stays as it is: treating a line as fenced suppresses checks, so a loose
reading buys a false positive at the price of a possible silent miss, which is the
one failure this guard must not have. Both limits are now stated with the reason.
The corpus is largely made of backticked constructs - rule names, code spans,
fenced blocks - so counting or extracting one is routine work here. The shell
refuses a literal backtick anywhere in a command, textually, so the obvious
one-liner is unavailable and quoting does not rescue it.

The prohibition already has a home in the global agent instructions and is not
restated. What is recorded is the consequence for this repo and the way through
it: put the pattern in a script, run it under the sandbox, and compose the
character where the pattern needs one.
…oses

Two ways past the HTML check and one silent miss, in code written an hour earlier.

The check keyed on a quoted value behind a tag on the same line. HTML requires
none of that. An unquoted destination is legal and every renderer follows it, so
deleting two quote characters walked straight through. A tag may straddle lines,
which CommonMark permits, and the destination on its own line was invisible. And
taking the first href before the closing angle read the one inside
alt="href='...'" and never the real destination beside it.

It now looks for the attribute rather than the tag, requiring whitespace or line
start ahead of the name. That one condition does the discriminating: an href
inside a quoted value is preceded by a quote, and data-href by a hyphen, so
neither qualifies, while a destination on a continuation line still does.

The fence bound was worse, because it inverted the reasoning committed with it.
That reasoning said the tight bound was safe since treating a line as fenced
suppresses checks. The tight bound caused exactly that suppression: an over-
indented closer was invisible, so the opener paired with the next visible marker
- a later block's opener - and swallowed the rendered prose between them, with
the marker count still even so the unbalanced-fence fail-safe never fired. A real
corpus link went unreported. Openers stay within three spaces of the margin, so a
stray marker cannot open a block and silence what follows; closers are now
accepted at any indent, because a closer that runs long hides lines and one that
ends early only exposes more. Both choices scan more, which is the only direction
this guard can afford.

Three smaller things. A carriage return on every line stopped any line looking
like a fence, disabling the matcher and its fail-safe on a CRLF checkout; line
endings are normalised first. The scanned-lines floor was presence alone, so a
file gutted to its heading passed a hard-zero guard; it is now a floor a procedure
has to clear. And the bare-name check now wants a hyphen: four declared rules are
single words, three of which this text already uses in their ordinary sense as
topology and operation-kind values, so a sibling value named concurrent would have
reported. Those four stay addressable in the dotted form.

Six behaviours survived the previous battery, and the six new ones needed cases
that discriminate: a closer longer than its opener, a marker with an info string
inside a block, and an unclosed fence following a closed one. Twenty neuterings,
twenty caught.
…refusal

The tool description said the server accepts no id but the workflow's first on a
fresh session. It does not. An id the workflow declares is committed and merely
warned about, by a check that runs after the session has advanced to it; an id the
workflow does not declare fails the activity lookup, with an error naming the id it
could not find rather than the one to use. The old bare word hit the second path.
The description now says what happens on each, because a claim about a return value
that the harness does not honour is worse than no claim.

Coverage was also on the wrong branch. dispatch_child returns from two places, and
the test only exercised the persistent-parent one - deleting the field from the
transient-promotion return left the whole suite green. That is the branch the meta
bootstrap takes, since it opens a session with no planning folder. Both sites are
now pinned independently, and dropping the field from either fails.
A second bootstrap into the same planning folder is what a resume is: start_session
opens a new transient meta parent, and promotion hands back the folder the earlier
run already filled. dispatch_child had no branch for that. It built a fresh child,
pushed it onto the new parent's empty triggeredWorkflows, and wrote the result over
the folder's session.json.

The damage was quiet, and that is the worst part. An embedded child's session_index
is derived from the folder plus the path to its slot, so the caller got back the
SAME index the previous run's child had - with an emptied session behind it. Zero
completed activities, no cursor, freshly seeded variables. A resumed run reported
the child it was resuming and then restarted at the first activity, and nothing in
the response said so.

The folder's children are now read before the promotion writes, and carried into
it at the array positions their indices were derived from. A child of this workflow
still running is resumed in place, keeping its cursor, completed activities and
variables; the entry's recorded index is refreshed in case the folder moved. A
completed child, or one of another workflow, is left as the record of that walk and
the new child appends beside it. A folder holding no session, an unreadable one, or
one that no longer satisfies the schema reads as no children, so promotion proceeds
as a first run - widening a resume into a restart costs one walk, where letting a
bad read abort the dispatch would cost the caller its session.

An entry may record no inline state, which is nothing to resume into; those are
passed over rather than resumed into an empty session.

Not addressed here: a persistent parent dispatching the same workflow twice appends
a second child rather than resuming the first. That path does not overwrite anything,
so it loses no work, and the meta bootstrap does not take it.
Nothing inspected an action set value. Of the three guards that parse a set at
all, one reads its target as a producer, one reads its description, one reads its
target - none looks at what it writes. So a value naming a variable without braces
was invisible to all twenty-two, and an unbraced name is the literal string.

That is the fault this branch spent two rounds on. The dispatch loop primed its
pointer from a bare word for as long as the loop existed; the word reached the
server as an activity id and the lookup failed on every single run. The two
spellings are one character apart and read the same to a person, which is what
makes a machine the right reader.

The rule is not new. Binding already draws the line between a rename and a
literal. This makes it checkable: a value shaped like a bag name - letters,
digits, underscores, optionally dotted - has to be braced. Shape is what
separates the two, because a literal here is a boolean, a number, null, an empty
collection, or hyphenated prose, and none of those matches. A rename written bare
is refused as well, though the corpus holds none; one spelling for reading a
variable is the point.

The second check is that a set has a target at all, which is where three corpus
findings came from - now fixed on the workflows branch.

Both are hard zero with no baseline. Ninety-eight set actions already satisfy
them, which is why this is the moment: added later it arrives with a debt list
and an argument over each entry.
The reattach preserved the child's session but told the caller nothing about it, so
the meta loop went on priming from the workflow's first activity and the walk
restarted regardless. The call that decided the reattach is the one place that knows
the cursor, so it reports resumed_activity - and only then, since a first dispatch
has no cursor to prefer over the first activity.

Also states, where the claim belongs, that get_workflow stays the home for a
session's own workflow metadata: this reports the CHILD's so a parent need not load
a bundle it will not execute. Two calls carrying the same field for different
subjects is a decision, and it now reads as one.
Three guards each needed the same two answers - which lines are illustration, and
where does this line point - and each had grown its own, narrower than the spec. So
a link written in a form CommonMark blesses slipped past whichever guard was meant
to catch it. The forms are not exotic: a destination wearing a title is ordinary
markdown, raw HTML is permitted inline, and a reference definition puts the
destination nowhere near its use.

The anchor guard was the one with something to lose. Its fence tracking counted
markers, so a three-tick example nested in a four-tick wrapper inverted the phase
and took real lines out of the scan; in the link half that is a silent miss, and in
the heading half it drops an anchor and invents a broken link. Rewiring it onto the
shared reader surfaced a link it had never seen, addressing a runtime artifact name
at an anchor no corpus file carries - fixed on the workflows branch.

The site-link guard read only double-quoted attributes, so a destination written
with single quotes went unchecked and an id written that way was invisible, which
reads as an anchor nothing declares. It now takes any spelling HTML permits.

Verified against the forms each guard used to miss: a titled destination, a
reference definition, a raw HTML anchor and an angle-bracket destination all report
now, while a resolvable anchor and a nested-fence illustration stay silent.

The lens-reachability guard reads only markdown table rows. Left alone: an anchor
written another way drops out of its map and the lens then reads as unreachable,
which over-reports rather than passing something broken.
… a session

Extracting the markdown reader carried its fail-safe along as though it were a
property of the matcher. It is not. A caller hunting destinations is safer reading
more lines, because suppressing one could hide a link. A caller collecting the
anchor targets a file declares is safer reading fewer, because an anchor it invents
makes a broken link resolve here and break in the reader's hands. Sharing one
direction gave the anchor guard the wrong one, and two corpus templates had the
unclosed fence to prove it - eight phantom anchors between them. The direction is
now the caller's to state, and an unclosed fence is a finding of its own rather than
a silent guess.

Three smaller reader faults. The angle-bracket form only counts when it wraps the
whole destination, so a lone leading angle is no longer stripped off the front of a
path. A destination may carry balanced parens, which the old pattern truncated at
the first one. And HTML attributes are read in order and matched by name, which is
what actually keeps a quoted value from being mined for markup - a space inside the
outer value satisfied the whitespace test the comment claimed did that job, so
alt="see href=x" yielded a destination. Matching the name exactly is also the honest
way to exclude data-href. The widened scheme skip is reverted where it only ever
cost: an authority is required again, so a relative path whose first segment carries
a colon stays checked.

On the server, the reattach had a path back to the damage it exists to prevent. Any
read failure was swallowed and treated as an absent session, and promotion then
wrote a fresh child over the folder - same derived index, emptied session. A rotated
server key is the likeliest cause and leaves the content perfectly intact, so it now
refuses the dispatch and says why; only genuine absence proceeds as a first run.

And whether a child had finished was read from the wrong field. The reference's
status is only flipped by the branch that notifies a persistent parent, and a
dispatched child carries no parentSession, so for these it reads running forever -
a completed workflow was resumed onto its close-out activity and ran it again. The
child's own state records completion, so that is what decides. A cursor parked on
the terminal sentinel is excluded too: next_activity accepts the sentinel, no
activity is declared under it, and the worker's fetch then refuses - the loop could
neither advance nor exit. Both copies of the index are refreshed as well, since
resolution matches the one inside the child's state rather than the reference's.
m2ux and others added 19 commits August 4, 2026 07:27
Two review rounds found five high or critical faults in the reattach, each fix
exposing the next layer: a read failure that walked back into the damage the
reattach exists to prevent, a completion test reading a field nothing sets, a
terminal cursor that stranded the loop, an index refreshed in the wrong of two
places, and a child abandoned at a checkpoint that no corpus step can resolve. That
is a design still finding its shape, and it does not belong inside a branch about
batching.

So it comes out whole, along with the resumed_activity it reported and the tests
that pinned it. dispatch_child once again builds a fresh child at slot zero. The
session-overwrite bug it was written to fix is therefore back, exactly as it has
always been on main - it predates this branch and is now written up on its own,
with the traces and the design both rounds produced.

What stays is what was sound and separately tested: the child's first activity is
still reported, so the meta loop primes its pointer from a declared value rather
than from a bare word the server cannot resolve.
The dispatch tool still told every caller that re-dispatching into an occupied
planning folder resumes the child there and reports the cursor it was left on. The
reattach that did this came out an hour ago; the field is asserted absent by a test
in the same commit. So the schema advertised behaviour the server does not have and
sent callers looking for a field that never arrives - and the behaviour it described
is the inverse of what happens, which is the bug the removal was made to stop
half-fixing. It now says what actually happens and names the issue.

Two guards also both reported the pre-session resource. The bootstrap guard refuses
every corpus link on that file, because nothing can be followed before a session
exists, so anything the anchor guard could find there was already a finding of the
other's - one bad line, two findings, one edit clearing both. The anchor guard skips
that file and says whose it is.

The set-action guard now reads workflow.yaml as well. A workflow root carries
checkpoint fragments, and a setVariable there writes the bag exactly as one inside
an activity does; eight of them sat outside the scan. All eight are booleans, so
nothing was escaping, but the guard is described as covering every setVariable and
now does.

And the pair-lookup rationale undercounted its own evidence: forty-five rule owners
carry a single-word name, not around thirty.
… a miss

The reader was extracted so three guards would stop each keeping a narrower answer
to the same question, and then reviewed only through those guards - whose corpora
exercise almost none of it. The bootstrap text carries no links and no fences at
all, so a hard-zero assertion over it says nothing about whether any of this works.
Eleven mutations of the module survived the whole suite. It has its own tests now,
and all fourteen fail.

They found two faults in last hour's fixes. Widening the destination arm to match
balanced parens made the whole pattern FAIL when the nesting outran it, so a
destination that used to be reported under a clipped name became no destination at
all - the direction the module's own header says a destination hunter cannot afford.
Truncated text in a finding is cosmetic; a link nobody reports is the fault the
module exists to prevent. The plain arm is back, with the reasoning written down and
a test that only requires a destination to come out at all.

And the conditional angle strip was still greedy, so a value that merely starts and
ends with the brackets was cut mid-string: the corpus writes placeholder idioms
freely, and one of them became a path nobody wrote. The predicate now excludes the
bracket it is looking for.

Three smaller ones. The attribute name had no left boundary, so a hyphen ahead of it
was simply skipped and matched anyway; a lookbehind states the boundary, and the one
construct where its shape shows is pinned rather than left to accident. The
attribute pattern was duplicated into the site-link guard by a commit titled "in one
place", and now comes from the one place. And the anchor guard reported an unclosed
fence in YAML, where a stray marker inside a block scalar is not a defect and
closing a fence is not a remedy - it is markdown-only, named as its sibling names
it, and both the OK line and the registry entry say the guard proves fence closure
as well as anchors.
The planning folder now holds what the two review rounds established, which the
issue links instead of citing branch commits.
The link loop iterates this generator while idsOf starts another pass over the same
module-level regex. It works because matchAll clones the pattern; the previous form
built a fresh one per call and was safe by construction, so the reason moved from
the code into nothing. An exec loop would share lastIndex between the two passes and
interleave them without a symptom.
An adapter is reached through the value of a variable: a harness kind becomes a file,
an operation kind becomes a Rules section inside it, and three callers apply whatever
that resolves to. No binding check sees any of it, because every other guard reads
technique bindings. The obligation that each adapter exposes the same three slices
lived in one prose sentence, and the set was enumerated twice - in a map that calls
itself authoritative and in the loader list whose own comment explains why a technique
named inside another technique's protocol has no other delivery path. Both must agree
and nothing checked it.

Measured before this: a fifth adapter declaring one of the three slices, a sixth
renaming a slice, a map row naming a file that does not exist, deleting a slice from
an existing adapter, and an orphan adapter file all passed the whole suite. All six
fail now, and the sixth case matters as much as the rest - a map reformatted past the
parse reads as unmeasured rather than as a pass, which is the failure a hard-zero
guard cannot afford.

The corpus already guards its largest set of this shape, the lenses reached through a
name, on reachability - and deliberately not on shape, because a lens declares its own
output shape as its contract. The adapters are the same construct with the opposite
property: their shape IS the contract, because callers dereference a slice name. This
is that asymmetry closed.

Writing the tests found one fault in the guard: a mapped kind whose file was absent
was reported both as missing and as unmapped, one defect twice. A kind is mapped
whether or not its file exists, so the registration is accounted for before anything
can skip ahead.
The envelope union carried exactly the two accepted result types, so "nothing came
back" was inexpressible and the stall on that path passed the whole suite. A third
scripted kind models it, and the resume step's effect encodes the recovery the
operation now carries.

Verified by removing that recovery from the effects table: the walk reports the stall
as the loop reaching its own cap instead of exiting on its condition, which is what
an orchestrator would have observed.
Every worker stub ends by naming activity-worker and saying to apply it from the
returned bundle. The bundle a worker gets is the workflow's activity techniques, the
activity's own, and the core worker set - and activity-worker is in none of them for
any workflow but meta, which is the only one declaring it. No tool loads a technique
by id, so a client worker was named a role it could not read, and with it none of the
rules that role carries: the control-plane ban, per-activity reporting, the batch
bound, the identity to pass on delivery calls.

It belongs in the core worker set by that list's own description - the refs every
activity worker needs at the activity level. This predates the batching work on the
dispatch path; the resume change made it reachable there too, which is how it
surfaced.
The guard matched the operation-kind vocabulary with a whole-file scan, and the
first match in the real map is not the step that sets it - it is the Outputs
section, which describes the same three names in passing, 347 bytes earlier. So the
guard was reading a sentence nobody edits to change behaviour: narrowing step 2 to a
single kind passed clean, and the adapters kept being checked against a set the map
no longer offered. Its own header claimed a reformat would surface as unmeasured. For
that half it did not, because the Outputs sentence kept the parse satisfied.

The test could not see it: the synthetic map had no Outputs section, so the fixture
had one occurrence where the corpus has two. It has one now, and the case is pinned.

Four more ways the set could diverge and pass. A name the pattern rejects dropped
silently out of the vocabulary AND out of every adapter, so renaming a kind to
something unparseable and deleting that section from one adapter passed - anything
name-shaped that fails to parse is now a finding rather than a skip. A rule heading
shown inside a fence counted as a declaration, which let a deleted slice pass behind
a documentation example; adapter Rules are read fence-aware, using the reader built
for the other guard. A slice declared twice was accepted by a Set, where three
callers dereference that name and two sections answering to it leave which one
applies undecided. And two kinds aliased onto one adapter reported the file as
absent from the core list, which was flatly untrue - that is now named as the
aliasing it is.

Row parsing is scoped to step 1 as well, so a row-shaped line in an example or a
rejected-alternatives note cannot inject a phantom. An absent map exits unmeasured
rather than throwing a stack trace at exit 1, which the sweep reads as findings.

The resolution-model doc tabulates the core worker set by hand and did not list the
role added to it, which no guard compares against the loader.
… figures

The claim that malformed and legacy state had been fuzzed across two dozen
shapes carried no test anywhere in the tree — the defect AP-144
`reference-without-provenance` names, applied to this branch's own record.
Giving it a carrier found two real gaps.

A history that is not a list is not iterable, and the `?? []` fallback covers
the field being absent but not its being an object or a number, so the `for…of`
over it threw where the intent was plainly to see no batch. A null entry threw
the same way in `batchActivities`, which had no entry guard at all.

A recorded size now has to be a measurement. `typeof chars === 'number'` admits
NaN and Infinity, and NaN compares false against the budget from both
directions — one poisoned event would have read as past budget for the rest of
the session, refusing every later activity and naming a character count of NaN.

The new test's own risk is being vacuous: every shape varies one well-formed
fixture, so fields written one level too high would read as another scope and
all 33 cases would pass having exercised nothing. That is what the fixture
assertion holds down — the first draft had exactly that fault, because it was
written from memory of the event shape rather than from the reader.

Re-measured `bench:batch` at this HEAD and propagated to all five carriers. The
figures ROSE because delivering `activity-worker` to client workers added a
fixed cost to every activity, and a bigger fixed floor collapses harder in a
batch: 223,157 characters to 161,027, a 27.8% saving against the 24.7%
recorded before.

The tool reference no longer says `get_workflow` is the only tool returning
`initialActivity`, which `dispatch_child` has contradicted since it started
carrying the child's across the boundary.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
m2ux and others added 3 commits August 5, 2026 10:22
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@m2ux
m2ux merged commit 2c6da98 into main Aug 5, 2026
4 checks passed
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.

Batched dispatch: one worker walks a run of activities instead of one

1 participant