Batched dispatch: one worker walks a bounded run of activities, and the server holds the bound - #424
Merged
Conversation
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.
6 tasks
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.
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>
7 tasks
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>
12 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
workflowsbranch, 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:
context_tokens × BATCH_HEADROOM_FRACTION × BUNDLE_CHARS_PER_TOKEN0.35BATCH_MAX_ACTIVITIES3The 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.80the 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.agentIdis the context that owns the whole walk by construction, which is whatcontextMode: "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_usagerecords one row per activity a dispatch covered, sharing anagent_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:batchwalks 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.tsasserts 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: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:batchnever 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.tsand the docs: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.jsonin 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:
dispatch_childdefaultsagent_idto"worker"and a resume rebindssession.agentIdto 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.batch_refusedis 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 reportsSEAL_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 missingdataenvelope; absentagentId, absentchars, 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-provenancenames — 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 anumber, which admitsNaN— 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 ofNaN.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
activity_dispatchedsize is the wholeget_activityresponse, 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.BATCH_MAX_ACTIVITIES=0became 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.may_continueis 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.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-activityis 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_continueare 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
deliveredCharsas 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-promptnow 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.tsis the half that walks iterations. A batch surviving a real checkpoint onto its next activity is covered end to end bytests/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 source —
AP-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 ofanchored-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 exists —
Principle 33 Pre-Session Prose Stands AloneandAP-145.discoverreturns the bootstrap procedure raw, before any session, so there is noget_resourceand noget_activityand 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:bootstraprefuses, 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 backtickedAGENTS.mdthat 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,testamong them — soplan.jsonwould read as an address. Probed against both defects and againstsession.json/repo.git/AGENTS.md/ an MCP URI / agroup::operationlabel: 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.tsreads 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_resultis 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, anddispatch_childreturned 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_childalready 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-sessiondeclares it as an output,initialize-sessionbinds 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: setvalue. Of the three guards that parse asetat all, one reads itstargetas a producer, one itsdescription, one itstarget— 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-bindingalready separates a rename from a literal.check:set-valuesmakes 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 asethas atargetat all, which is where three corpus findings came from. Both are hard zero with no baseline: everysetaction and every checkpointsetVariablein 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-setproves 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
## Outputssection 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## Outputsat 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.tsnow 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.ts—dispatch_childreports the child workflow's first activity.scripts/check-set-action-values.ts(new,npm run check:set-values) withtests/set-action-values.test.ts— everysetaction, and every checkpointsetVariable, names where it writes and braces what it reads.scripts/markdown-refs.ts(new) withtests/markdown-refs.test.ts— destinations and fences read in one place, withscripts/check-resource-anchors.tsandscripts/check-site-links.tsrewired 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) withtests/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.ts—activity-workeris delivered to the workers whose stubs name it.tests/batch-bound-totality.test.ts(new) — the bound answers over any history it is handed, andsrc/utils/batch.tsno longer throws on a history that is not a list or counts aNaNsize 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 whatdispatch_childreturns 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 inscripts/guards.ts, withtests/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 inget_activity,_meta.batch, and per-activity usage inrecord_usageandprojectUsage.src/config.ts— the two settings, their clamping, and the calibration behind the defaults.src/schema/state.schema.ts— thebatch_refusedevent.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) plustests/config.test.ts. The loop-gate test reads the client loop'swhen: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.tsandsrc/utils/session/params.tsthat still said a worker handled exactly one activity.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.session.json,repo.git,AGENTS.md, an MCP URI, agroup::operationlabel, 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
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
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