docs(spec): P6 — VM process manager + session mux, density without Kubernetes - #244
Conversation
…ernetes Records the deployment-model slice that P5 §6 and ADR-0032 defer — ScaledJob → elastic pool, and rossoctl#55's overload shift from pod-level to session-level — on a non-Kubernetes substrate, with E8 (density/saturation) and E9 (deployment-tier comparison) as the deliverables. The premise is that scale-to-zero and high density are substitutes: once one process holds N sessions the deployment never idles at zero, so Knative's autoscaler stops earning the cold start it charges. That is not speculative — EXPERIMENTS.md:96 already attributes E6's p95 blowup to "LLM latency + Knative cold-start ... not the sandbox". Tracing the tree at c12a97c showed how little is actually Kubernetes-shaped: the SandboxTransport gRPC path, Redis presence and leases, session state, the queue, leaf results and the plain node:http surface are all substrate-neutral, and MU1 already put credentials behind CredentialStore. Two measurements decide the shape — E6/E7's 2-8% sandbox duty (so nothing below the worker line changes) and E2's constant 6-entry rehydration (so stateless routing is the honest default and affinity becomes a sweep variant rather than a design commitment). One code-level blocker found: select-sandbox.ts:87 lists pods unconditionally via kubectl before the gRPC branch, so the presence path cannot run on a host without kubectl despite needing nothing from it. Fixed by an explicit SH_SANDBOX_DISCOVERY source selector, not by swallowing ENOENT — which would turn a broken kubectl on the cluster path into a silently empty pool. The load-bearing decision is that the supervisor hands off accepted sockets over IPC and never touches a response byte. A byte-proxying supervisor would put its own event loop inside the ceiling E8 exists to find; hand-off removes that confound structurally, and settles the supervisor's language as Node. Also fixes the honesty crux of the claim: because routing is stateless, an idle session costs ~900 bytes, so "N concurrent sessions" is fixed to mean concurrent in-flight turns, with sessions-addressable kept separate as a Redis capacity statement. The spec is written to be sufficient on its own: §3.8 names the configuration surface, §3.9 states the supervisor/worker IPC contract completely including the bounded over-admission it accepts, §3.10 gives the ordered steps and states precisely what P5 gates (mixed-subject rungs and the isolation claim — not the work), and §9 fixes the file layout, the driver homes, and detectKnee's c=1 baseline requirement rather than leaving them to be chosen. Registers P6 in the milestone registry and ADR-0034 in the ADR index. Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
… fast-channel gap Follows a review question about the sandbox tier: does the VM path still use a static pool and gRPC streams? Tracing the wiring to answer it turned up a gap that affects E9's validity, not just its performance. extension.ts:38 and :49-51 show opts.transport overriding BOTH transport tiers, and run-leaf.ts:786 passes exactly that for a leased gRPC record. So the gRPC path has NO persistent fast channel: every read/write/edit/ls/find is a full Exec RPC ending in a fresh `bash -c`, where the kubectl path serves the same op as one nonce-framed line on a long-lived bash. Pre-existing on the remote path, but file ops are the highest-frequency tool calls, so at W×S sessions the churn concentrates on K containers. The consequence for E9 is the important one. Left to their defaults the two arms would not match — the Knative arm resolves pods and gets the fast channel, the VM arm runs gRPC and does not — so the comparison would charge the deployment tier for a transport difference, biased against the VM. E9 now pins BOTH arms to the relay + gRPC transport, which the cluster can already do via SH_REMOTE_SANDBOX against the in-cluster relay. Configuration, not code, and it makes E9 vary the deployment tier and nothing else. The optimization is deliberately NOT P6's: persistent-exec.ts is kubectl-specific only in the binary name (:84) and buildPersistentKubectlArgs (:12), while framing, cap-at-source and fallback are transport-agnostic and already declare producer-side-cap — so a container-exec variant is a parameterized argv, not a new protocol. But it lifts both substrates equally, so folding it in would improve both E9 arms at once while adding transport surface to a slice that exists to measure the deployment tier. Tracked as rossoctl#245 on the ST track. Also: - §3.1 records that the pool is self-registering, not statically configured — presence is written on a live Attach stream and removed on close, so a docker run joins the pool and a stop leaves it. - §3.1a states the relay-stays trade with its real price, rather than the incomplete "not worth a fourth transport to save a loopback hop". - §5.2 replaces the prose metric list with a table mapping each metric to the tier it attributes a knee to, adding per-file-op latency and sandbox-container CPU. Without those two, a sandbox-bound run reads as a harness density limit — the error E6 caught in itself. E7 validated converge correctness at 6 concurrent refs; P6's rungs go well past that. - §2.3 notes E6's duty numbers were measured on the kubectl path, so they are the right provisioning input AND an open question at this concurrency. Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
|
Pushed The finding. Why it matters for E9. Left to their defaults the two arms would not match: the Knative arm resolves pods and gets the fast channel, the VM arm runs gRPC and does not. E9 would then charge the deployment tier for a transport difference — biased against the VM, and the same class of confound E6 caught in itself. §5.3 now pins both arms to the relay + gRPC transport, which the cluster can already do ( Why the fix isn't in this PR. Also in this push:
|
cwiklik
left a comment
There was a problem hiding this comment.
Docs-only: the P6 spec (618 lines) plus ADR-0034, both registered. I verified every file:line citation in the spec and the ADR against the tree at c12a97c through the GitHub API rather than a local checkout — git fetch fails on TLS interception in my environment, so a local tree would have been stale at 02109bc and would have made resolve-pod.ts look like a phantom citation. Flagging the method because it is load-bearing for what follows.
The citations hold up: detectKnee's c === 1 baseline throw (sharing.ts:14-15), handler's module-privacy at server.ts:495 and the startServer() boundary at :576-577, extension.ts:38 / :49-51's both-tier transport override, resolve-pod.ts:32's bare spawn('kubectl'), select-sandbox.ts:87's unconditional pod list, and the E6/E7 duty quotes are all as described. The design reasoning is unusually well-grounded in existing measurements, and the socket hand-off argument in §3.2 is the right call for the reason given.
One must-fix: stickyBySession (§3.4/§3.9) is specified to route from the request head, but the session id is read from the JSON body (server.ts:93-101), so the sweep variant cannot be implemented as written — and the natural fix puts a body parse on the supervisor's accept path, which is precisely the confound §3.2 exists to remove. Two suggestions: the "2–8% duty" range and the "29–48:1" ratio come from different experiments and are arithmetically incompatible at the point where §5.4 makes both drive stub calibration and sandbox provisioning; and S is named a session cap but must mean an in-flight-turn cap under stateless routing — the exact conflation §5.1 fixes the vocabulary to prevent. Plus two nits.
Author: pdettori (MEMBER — maintainer)
Areas reviewed: Docs (specs + ADRs), citation verification against c12a97c
Agent/IDE config (.claude/.vscode): none
Commits: 2 commits, all signed-off: yes (Assisted-By: used, not Co-Authored-By, per CLAUDE.md)
CI status: passing (12/12 green, including DCO, lint, CodeQL, Trivy)
|
|
||
| - The worker builds `createServer(handler)` and **never calls `listen()`**; on each received socket it | ||
| does `server.emit('connection', socket)`. | ||
| - Sticky mode (§3.4), which must read the session id, pre-reads only the request head and |
There was a problem hiding this comment.
must-fix — stickyBySession cannot be implemented from the request head, so the sweep variant as specified is not buildable.
Verified at c12a97c: sessionId is read from the JSON body — packages/knative-server/src/server.ts:93-101 does JSON.parse(body) then const { sessionId, prompt } = parsed. And /turn is matched on method plus exact URL only (:564, req.method === 'POST' && req.url === '/turn'), so there is no path segment or query string carrying it either. A supervisor that pre-reads only the request head therefore has nothing to key on; socket.unshift(head) faithfully restores a request whose session id the supervisor never saw. The SSE branch (:107-108) takes the same body, so it does not help.
The reason this is blocking rather than a wording fix: the obvious repair is to buffer and JSON-parse the body in the supervisor, which puts request bytes and a parse on the accept path — partially reintroducing the confound §3.2 removes structurally, and turning sticky's supervisor overhead into a term E8 must measure rather than something that is free. That changes the cost side of the very knob §3.4 says the experiment will price.
Two cheap ways out:
- Have the sweep driver carry the id in the head —
/turn?sid=<id>or a request header — read only bystickyBySession, leaving the body contract and the stateless default untouched. §3.9'shead?row then describes a genuinely head-sized read. - State explicitly that sticky buffers and parses the body, and that its supervisor-side cost is a measured term in the E8 sweep rather than assumed negligible.
Either is fine; §3.4 and §3.9 need to say which, since §3.10's ordered steps currently treat sticky as a small variant on top of stateless routing.
There was a problem hiding this comment.
Fixed in a900d58 — sticky now keys on an X-SH-Session header, option 1, with the body contract untouched.
One addition your analysis surfaces: a query string is not just inelegant here, it would not route. :564 matches req.url === '/turn' by exact equality, so /turn?sid=<id> 404s before any handler sees it — option 1 via a path or query would have required touching that match, i.e. a real change to the Knative path this slice promises not to make. A header costs nothing: the supervisor already pre-reads headers in sticky mode, and X-SH-Subject (§3.6) establishes the precedent for a head-carried key.
§3.4 now carries three paragraphs: the constraint with your 93-101 / :564 citations, the rejected body-parse alternative with §3.2 as the reason, and the price stated — sticky is measurable but not adoptable as-is, since a production client would have to start sending the header. That last one is the part I would have been tempted to leave in a footnote; it belongs in the finding, because E8 prices sticky's benefit and the header is the precondition that benefit is contingent on.
Also: §3.9's head? row now says "headers only", §3.10 step 6 names sticky as the sweep arm rather than leaving it implicit, §7 adds a unit test for the mechanic (keys on the header, reads no body bytes, unshift leaves the worker's parse intact), and ADR-0034's routing paragraph records the constraint and its consequence.
|
|
||
| ### 2.3 The sandbox tier is not the bottleneck, and we have the number | ||
|
|
||
| E6/E7 measured per-sandbox duty at **2–8% of leaf wall-clock on both Kind and OCP**, giving |
There was a problem hiding this comment.
suggestion — the "2–8% duty" range and the "N ≈ 29–48:1" ratio come from two different experiments and cannot both be true of the same measurement.
Verified at c12a97c:
deploy/knative/EXPERIMENTS.md:96(E6): the pinned sandbox is only "~6–8 % busy (duty 0.06–0.08)".:168(E7): "busy only ~2–4 % of leaf wall-clock (duty 0.021 Kind / 0.035 OCP → N ≈ 29–48:1)".
So "2–8%" is the union of two experiments' ranges, while "29–48:1" derives only from E7's half: 1/0.035 ≈ 29 and 1/0.021 ≈ 48. At E6's 8% duty the ratio is roughly 12:1, not 29–48:1. (Incidentally "on both Kind and OCP" is E7's phrasing at :168 — E6 at :96 is the single-pinned-sandbox measurement.)
This matters because §5.4 makes both numbers drive the same two decisions — the stub's tool-call rate and how many sandbox containers a run provisions. Calibrating the stub at the top of the stated range while provisioning at 29–48:1 under-provisions the sandbox tier by about 2.4×, which produces exactly the sandbox-bound run that §5.2's two new saturation metrics exist to detect. Better not to build it into the calibration.
Suggest picking one basis and naming its experiment: E7's 2–4% / 29–48:1 for provisioning (they are the same measurement, so they stay consistent), with E6's 6–8% cited separately as the higher-duty case a heavier workload produces — and then say which one the E8 driver calibrates against. ADR-0034's Context paragraph carries the same pair, so both need the fix.
There was a problem hiding this comment.
Split in a900d58 — but landing on the opposite basis from the one you suggested, on the strength of a line neither of us cited.
EXPERIMENTS.md:65, inside E6's own section, reads: "This supersedes the earlier single-N figure (N ≈ 29–48:1), which used a trivial marker.txt leaf with no real converge." So the document itself retires 29–48:1 for real-converge work. Provisioning from E7's row would have had the spec provision from the figure the repo declares superseded.
The arithmetic supports the same direction. E7's 0.021/0.035 comes from a 13.9 s leaf wall (:121, :161) against E6's ~6–7 s: same fixed git plumbing, smaller share of a longer turn. So 29–48:1 is the light-leaf end and 12–24:1 the code-review end — and provisioning from the lower ratio is also the conservative direction against §3.1a's per-op process churn, which is the hazard §5.2's two new metrics exist to catch. Two reasons pointing the same way.
Your framing of the fix is what got applied, though: one basis, named, per decision. §2.3 now tables both with per-experiment cites (E6 real code review 0.06–0.08 → 12–17:1 OCP, ~20–24:1 Kind; E7 mixed-ref converge 0.021/0.035 → 29–48:1) plus the invariant N ≈ 1/duty, so a blended pair fails a visible check. §5.4 pins the E8 driver to the E6 row for both decisions — stub calibration and container count — names E7's row as a lighter sweep point taken whole, and asserts K ≥ ceil(W × S × duty) so mixing cannot happen by accident. ADR-0034's Context paragraph carries the same split.
Two things your note prompted me to write down rather than resolve: E6's 6–8% appears both as per-leaf duty (:94) and as the pinned sandbox's busy fraction at C_max (:96), which are not the same quantity even where the range coincides; and every run now records which basis it used. Both are in §2.3's closing paragraph — flagged rather than papered over, since E8 will re-measure it at concurrency the kubectl-path numbers were never taken at.
("on both Kind and OCP" was indeed E7's phrasing — gone from §2.3 along with the blended range.)
| | Variable | Default | Meaning | | ||
| | ------------------------------ | ------------------ | ------------------------------------------------------- | | ||
| | `SH_WORKERS` | `os.cpus().length` | W — worker processes in the pool | | ||
| | `SH_SESSIONS_PER_WORKER` | _required_ | S — per-worker soft cap; the admission threshold (§3.5) | |
There was a problem hiding this comment.
suggestion — S is named a session cap throughout, but under the stateless default it can only be an in-flight turn cap. This is the conflation §5.1 fixes the vocabulary to prevent, so it is worth applying that vocabulary here too.
SH_SESSIONS_PER_WORKER, "per-worker soft cap" (§3.8), "all workers at their session cap" (§3.5), and "in-flight session count" (§3.4/§3.9) all name a session quantity. But with leastInFlight stateless routing no worker owns a session between turns — the session lives in Redis and the next turn can land on any worker. So the only thing a worker can report, and the only thing S can bound, is concurrent in-flight turns. Either rename to SH_TURNS_PER_WORKER, or keep the name and define S explicitly as in-flight turns in §3.8.
Same fix sharpens §3.9's staleness argument, which currently asserts over-admission "by up to W" without giving the direction of the error. With optimistic increment on hand-off, the stale case that actually yields over-admission is a second turn arriving on a kept-alive socket: in-flight rises with no hand-off for the supervisor to count, so its estimate stays low until the next load message. The opposite lag — a turn finishing before its load lands — makes the estimate high and produces spurious 429s. That is under-admission, and it is arguably the worse failure for this experiment: it silently truncates E8's rungs by refusing offered concurrency the pool could have served, so the knee reads early with nothing in the data to distinguish it. Worth logging alongside the over-admission events §5.2 already records.
There was a problem hiding this comment.
Both parts fixed in a900d58, including the rename: SH_SESSIONS_PER_WORKER → SH_TURNS_PER_WORKER.
Rename rather than redefine, because the reasoning generalizes past the one variable — §3.1's diagram and component table, §3.3's "S concurrent sessions is S concurrent awaits", §3.4/§3.9's counts, §3.5's cap, §3.6's output-guard blast radius, §5.2, §5.7's claim template and ADR-0034 all said sessions where only turns are bounded. §3.8 now states it once, as §5.1's vocabulary applied to the configuration surface, and adds the sticky wrinkle you implicitly raise: under stickyBySession a worker does retain affinity for sessions it has served, but S still bounds admission — resident session count becomes a separate measured quantity, not a second meaning for S.
On staleness: your direction analysis is right and §3.9 now tables both cases with their sign. Over-admission from a second turn on a kept-alive socket (supervisor never sees that connection again, so no increment; estimate reads low) — bounded at ≈1 per worker and self-correcting on the next load. Under-admission from a turn finishing before its load lands (estimate reads high → spurious 429) — and this is now called out as the worse case for E8, in your words: it refuses concurrency the pool could have served, so the rung truncates and the knee reads early with nothing in the data to distinguish it from a real ceiling.
That last point changed the instrumentation, not just the prose. §5.2 gains a metric row — every 429 recorded with the supervisor's estimate at refusal against the next load per worker — so a refusal the pool could have absorbed is identifiable after the fact. Without it, an under-admitting run is indistinguishable from a genuine finding, which is exactly the class of error E6 caught in itself. I also dropped the bare "over-admit by up to W sessions" claim in favour of stating what actually bounds it (in-flight changes inside one round trip), since the old sentence asserted a bound without a mechanism.
| `git submodule update --init --recursive`, then `cd pi-fork && npm ci && npm run build`, then | ||
| `pnpm install` at the root. | ||
|
|
||
| **Plans are not committed here.** `docs/plans/` is gitignored and ephemeral by house convention |
There was a problem hiding this comment.
nit — README.md:233 does not state this convention. Verified at c12a97c: README.md is 350 lines and contains no occurrence of docs/plans, "gitignored", or "ephemeral"; line 233 is about HOME/REDIS_URL and byte-identical bundles.
The convention itself is real and stated almost verbatim — just elsewhere. .gitignore:27-30:
# Implementation plans are local-only, ephemeral work artifacts (see docs/plans/README.md).
docs/plans/*
!docs/plans/README.md
Suggest citing .gitignore:27-30 (or docs/plans/README.md) instead. Flagging a one-line citation fix only because the PR body states every line-number citation was verified against the tip — the rest of them are accurate, including the non-obvious ones, so this is the lone exception rather than a pattern.
There was a problem hiding this comment.
Fixed in a900d58 — now cites .gitignore:27-30 and docs/plans/README.md.
Confirmed your read: README.md:233 is the --home/--redis-url paragraph about HOME/REDIS_URL and byte-identical bundles, and the file is 350 lines with no occurrence of docs/plans. The convention is verbatim at .gitignore:27-30, which is presumably where I read it and then mis-attributed.
| | `SH_WORKERS` | `os.cpus().length` | W — worker processes in the pool | | ||
| | `SH_SESSIONS_PER_WORKER` | _required_ | S — per-worker soft cap; the admission threshold (§3.5) | | ||
| | `SH_ROUTING_POLICY` | `leastInFlight` | `leastInFlight` \| `stickyBySession` (§3.4) | | ||
| | `SH_SANDBOX_DISCOVERY` | see §4.2 | `pods` \| `records` \| `both` | |
There was a problem hiding this comment.
nit — SH_SANDBOX_DISCOVERY puts a third sandbox knob under a different prefix from the two it sits beside.
The other four new variables follow the dominant env convention cleanly (SH_MODEL_*, SH_BUDGET_*, SH_RELAY_*). The awkward one is this: the existing sandbox namespace is KAGENTI_SANDBOX_* — KAGENTI_SANDBOX_POOL_SELECTOR (select-sandbox.ts:75) and KAGENTI_SANDBOX_CAP — and §3.8 lists both as inherited a couple of rows below this one, so the split is visible inside a single table.
Either prefix is defensible (SH_ for new harness-owned config, KAGENTI_ for continuity with the sandbox layer). The ask is only that it be a stated decision rather than an accident, since §7's default-semantics pin already establishes that this spec settles this kind of question — and SH_SANDBOX_DISCOVERY is the variable an operator reads next to KAGENTI_SANDBOX_CAP when a pool comes up empty.
There was a problem hiding this comment.
Stated as a decision in §3.8 as of a900d58, keeping SH_.
The line I drew: KAGENTI_SANDBOX_* addresses the sandbox layer's own objects — KAGENTI_SANDBOX_POOL_SELECTOR is a label selector (select-sandbox.ts:75), KAGENTI_SANDBOX_CAP is a lease cap (run-leaf.ts:391) — while SH_* is harness-owned config about which mechanism the harness uses. Discovery-source selection is the second kind.
What settles it beyond taste is the sibling: SH_REMOTE_SANDBOX is already SH_, it is the flag whose branch this seam gates, and §4.2's default is defined in terms of it (pods, or both when SH_REMOTE_SANDBOX=1). Splitting those two across prefixes would be worse than the split you flagged, and renaming SH_REMOTE_SANDBOX for symmetry is not worth breaking deployed config over. §3.8 says all of that, including the last clause, so the next reader gets the reasoning rather than re-deriving it.
Your framing of the ask — a stated decision rather than an accident — is what I applied; the operator reading SH_SANDBOX_DISCOVERY next to KAGENTI_SANDBOX_CAP on an empty pool now has a rule to hang them on.
Addresses the two reviews on #242. Ten changes, no change to the three ordered steps in §3.2 or to the shape of the implementation. Blocking finding (§4, SettingsManager): - The three lines cited as "project-scope mutators" were `setProjectTrusted()` calls (`settings-manager.ts:447`), which flip an in-memory boolean and write nothing. Replaced with the set that can write — `updateProjectSettings` (`:635`) → `assertProjectTrustedForWrite` (`:527`), reached from `setProjectPackages` (`:942`), `setProjectExtensionPaths` (`:958`), `setProjectSkillPaths` (`:974`), `setProjectPromptTemplatePaths` (`:990`), `setProjectThemePaths` (`:1006`), plus `saveProjectSettings` (`:618`) and the `"project"` branch of `enqueueWrite` (`:552-553`). - Re-ran the reachability audit against those five, which the review could not settle because GitHub code search does not index the pinned fork. Their only non-test callers are the interactive TUI (`config-selector.ts:484-490`, `:558`) and `pi packages` via `package-manager.ts:798`/`:806`/`:824` → `installAndPersist` (`:989`) / `removeAndPersist` (`:1014`) → `package-manager-cli.ts:601`/`:606`, both also requiring `local: true` (`:783`, `:814`, `:994`). Neither exists in server mode: `sdk.ts` constructs no `PackageManager` and `run-turn.ts` passes none (`:499-504`). The conclusion survives; only the evidence did not, and both are now recorded. - Residual #1 retargeted at the five `setProject*` methods. MU1 composition (§3.6 of the now-merged control-plane spec): - §3.2 step 1 keeps "no new credential *path*" but requires the tagged `UpstreamCredential` union, with both silent failure directions. - §3.2 declines to pin "an inbound `X-SH-Subject` is always honoured"; pins the durable form instead — the subject is resolved from the request, never from process state — with `subject_conflict` (400). - §5 lock-down row rescoped from process to **environment**, citing ADR-0033's accepted divergence; notes the stronger claim should be gated on direct mode being disabled rather than weakened. - §5 fail-closed row rephrased on resolvability, so MU1 need not invert it. P6 (#244) entry-point asks: - §3.2 step 3: land the scrub as an exported function `startServer()` calls, so the VM worker — a third entry point that never calls `listen()` — is not the one path that runs unscrubbed at W×S. - §4: `stdoutTakeoverState` pin reworded to "any non-CLI entry point". - §4: verdicts scoped to correctness and silent on liveness, pointing the liveness question at the deployment-model slice. - §5: write the two-tenant test against a parameterizable entry point. - Residual #2 expanded with the `cwd` → single-lockfile chain, the absence of an async `withLock`, the `AuthStorage` non-hazard (`auth-storage.ts:76-98`, but `getApiKey` `:464` → `refreshOAuthTokenWithLock` `:409` → `withLockAsync` `:417`), and P6 §5.2's event-loop lag p99 as the existing instrument. Nits and coherence: - Quoted `session-manager.ts:1421` in full, including the elided `sessionDir` branch. - `redis-backend.ts:6-7` → full path, with the `buffered-redis-backend.ts` disambiguation. - §6 names P6 as the deployment-model slice's realization and lists the three forward references it consumes; §9 adds MU1 and ADR-0033. Two review details corrected in passing: `package-manager-cli.ts` lives at `packages/coding-agent/src/`, not `src/core/`, and the `AuthStorage` spin starts at `:76`. Refs #239 #244 Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: cwiklik <cwiklikj@gmail.com>
…ne S as turns Addresses review on rossoctl#244. **must-fix — `stickyBySession` was not buildable as written.** The session id is read from the JSON body (`server.ts:93-101`) and `/turn` is matched on exact URL equality (`:564`), so a supervisor that pre-reads only the request head has nothing to key on — and a query string would not even route. Sticky now keys on an `X-SH-Session` **header** set by the sweep driver, travelling in the head as `X-SH-Subject` already does. The rejected alternative (buffer + JSON-parse in the supervisor) is stated with its reason: it puts bytes and a parse on the accept path, which is the confound §3.2 exists to remove structurally. §3.4 also states the price — sticky is measurable but not adoptable without that client-contract addition — and §7 pins the mechanic with a unit test. **The duty pair was arithmetically incompatible.** "2–8% duty" was the union of E6's 6–8% and E7's 2–4%, while "29–48:1" derives only from E7's half; §5.4 had both driving stub calibration *and* sandbox provisioning, which under-provisions the sandbox tier ~2.4×. §2.3 now tables the two bases with per-experiment cites and the invariant N ≈ 1/duty, and §5.4 pins one basis for both decisions with a driver assertion (`K ≥ ceil(W × S × duty)`). Note this lands opposite to the review's suggested basis: `EXPERIMENTS.md:65` records that E6's real-converge finding *supersedes* "the earlier single-N figure (N ≈ 29–48:1), which used a trivial `marker.txt` leaf with no real converge". So P6 provisions from E6's 6–8% / 12–24:1 — the authoritative reading and the conservative direction against §3.1a's process churn — with E7's row available whole as a lighter sweep point. **S was named a session cap but can only bound in-flight turns.** Under stateless routing no worker owns a session between turns, so `SH_SESSIONS_PER_WORKER` is now `SH_TURNS_PER_WORKER`, with §5.1's vocabulary applied through §3.1/§3.3/§3.4/§3.5/ §3.9/§5.2/§5.7 and the ADR. §3.9 also gives the staleness error a direction: a second turn on a kept-alive socket reads low (over-admission, ≈1 per worker, self-correcting), a turn finishing before its `load` lands reads high (under-admission via spurious `429`s) — the worse case for E8, since it truncates rungs and makes the knee read early. E8 now records both. **Nits.** The `docs/plans` convention is cited at `.gitignore:27-30` rather than `README.md:233`, which is about `HOME`/`REDIS_URL`. And §3.8 states the `SH_` vs `KAGENTI_SANDBOX_*` split as a decision: `KAGENTI_` addresses the sandbox layer's own objects, `SH_` is harness-owned mechanism config, and `SH_SANDBOX_DISCOVERY`'s sibling switch `SH_REMOTE_SANDBOX` is already `SH_`. Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
|
Pushed The must-fix repair had a second constraint the review did not have. Sticky now keys on an The duty split landed on the opposite basis from the suggestion, on the strength of Also in this push: On the verification method flagged in the review: it caught the one citation that was wrong and correctly read the two numbers as incompatible. Worth noting that reviewing against the tip is what made |
cwiklik
left a comment
There was a problem hiding this comment.
Second pass, verifying a900d58 against main and the pinned fork rather than re-reading the argument. Both blocking items are properly fixed, and one of them is fixed better than I asked for.
On the duty split: the commit lands opposite my suggested basis and is right to. EXPERIMENTS.md:65 does say E6's real-converge finding supersedes "the earlier single-N figure (N ≈ 29–48:1), which used a trivial marker.txt leaf with no real converge" — so provisioning from E6's row is the authoritative reading and the conservative direction. My suggestion pointed at E7's pairing for internal consistency and would have under-provisioned. §2.3's two-row table with per-experiment cites is the right shape, and every derivation in it checks out arithmetically against the source rows.
On sticky: keying on X-SH-Session in the head is the correct resolution, and the rejected alternative is stated with the right reason. SH_TURNS_PER_WORKER is carried through §3.1/§3.3/§3.4/§3.5/§3.8/§3.9/§5.1/§5.2/§5.7 and the ADR, with the only surviving mention of the old name being the deliberate "it is why the variable is not SH_SESSIONS_PER_WORKER". §3.9's new staleness table is a genuine addition — naming under-admission as the dangerous direction because it truncates rungs and makes the knee read early is the same category of catch as E6's tier attribution.
Verified against main: server.ts:93-101 (JSON.parse(body) at :95, const { sessionId, prompt } = parsed at :101), :564's exact URL equality, .gitignore:27-30 plus the existence of docs/plans/README.md, select-sandbox.ts:75's selector, run-leaf.ts:391's cap, and all six duty/N figures against EXPERIMENTS.md:62-65, :76-80, :88-98, :121, :161, :168.
Requesting changes on one new must-fix, which is the previous finding one layer down rather than a regression: sticky's key is now per-request, but socket hand-off makes affinity per-connection, so a driver that pools connections across sessions turns the sticky arm into a measurement of its own connection reuse. §3.4 already names keep-alive reuse as real and §3.9 already relies on it — only sticky's dependence on it is unstated. One clause plus a run-record field plus one assertion in the test §7 already adds.
One suggestion (§5.4's default pairs the OCP duty with the two-cluster ratio, which fails §2.3's own N ≈ 1/duty test and the driver assertion three lines below it) and one nit (X-SH-Session vs MU1's "session token"), both inline.
Docs-only, all 12 checks green, three commits signed off, Assisted-By correct.
Prior review: #244 (review above) · P5 amendments now landed on docs/239-p5-spec-amendments include §3.2 step 3's shared scrub function, §4's non-CLI entry-point pin, and §5's parameterizable test entry point — the three forward references this spec consumes.
| and turning sticky's supervisor cost into a term E8 would have to measure rather than one that is | ||
| free. That would corrupt the cost side of the very knob this section says the experiment prices. | ||
|
|
||
| **The price, stated:** sticky is therefore **not adoptable as-is** — a production client would have to |
There was a problem hiding this comment.
must-fix — the routing key is now per-request, but hand-off makes affinity per-connection. Sticky can still measure the wrong thing.
The header fix is right and it closes the previous finding. What it leaves open is one layer down: the supervisor inspects a connection once, at accept time, and then gives the socket away. After that it holds nothing — so every subsequent request on that same keep-alive connection reaches the worker the first request's X-SH-Session selected, and the supervisor cannot see, let alone re-route, a second session id arriving on it.
This is not hypothetical in this document. §3.4 argues against connection-level round-robin precisely because "a driver reuses keep-alive connections", and §3.9's staleness table already relies on the same fact for the over-admission row ("A second turn arrives on a kept-alive socket already handed off"). So the spec knows requests share connections; only sticky's dependence on that is unstated.
The consequence lands on the number, which is why I'm flagging it rather than filing it as a nit:
- If the E8 driver pools connections across sessions — the default in most HTTP clients, and
undici/node:httpagents do it unless told otherwise —stickyBySessiondegenerates toward whatever the pool does. Affinity hit-rate becomes a property of the driver's connection reuse, not of the policy. - The arm then prices warmth at roughly zero and reads as "affinity isn't worth it", with nothing in the recorded data separating that from a genuine null result. §5.2's whole discipline is that a confound must be identifiable after the fact; this one currently isn't.
- It also quietly weakens §3.4's stated price. "A production client would have to start sending
X-SH-Session" is necessary but not sufficient — it would also have to not multiplex sessions over one connection, which is a larger client-contract ask than the header alone.
Cheap to close, and it needs no design change:
- One clause in §3.4: sticky affinity is connection-scoped, so the sweep arm requires one connection per session (a per-session agent, or
Connection: close). - Record it per rung in §5.2 alongside the basis, so a sticky run that violated it is identifiable rather than invisible.
- Extend §7's new unit test — it already asserts the header read and
socket.unshift(head); asserting that a second request on the same socket is not re-routed pins the semantics as deliberate rather than accidental.
Alternatively state it as a limitation and drop the per-request framing: affinity is per connection, keyed by the first request's session id. Either resolution is fine; leaving it implicit is the thing that costs an experiment.
There was a problem hiding this comment.
Fixed in 9f87f11 — all three of your closures, plus the framing from your alternative, since they are not in tension.
You are right that this is the previous finding one layer down, and right that the spec already knew the fact twice over without connecting it to sticky. §3.4 now derives it rather than asserting it: affinity is connection-scoped because §3.2 gives the socket away, keyed by the first request on the connection, so a second id on that connection is neither seen nor re-routable. Stating it as a derivation matters — it makes clear there is no version of hand-off where sticky is per-request, so nobody later tries to "fix" it.
The three closures as landed:
- §3.4 states the driver requirement — one connection per session for the sticky arm, a per-session agent or
Connection: close— with your reason recorded, thathttp.globalAgentandundicipool by default. - §5.2 gains a run-record paragraph: connections-per-session per rung, which must be 1, sitting beside the duty basis. Both are conditions a rung can silently violate, so they belong together.
- §7's unit test asserts a second request on the same socket is not re-routed even carrying a different id — pinned as deliberate, with the note that this is what stops a later reader from turning it into a body parse.
Your third bullet was the one I would not have caught: "a production client would have to start sending X-SH-Session-Id" was necessary but not sufficient. The price paragraph and ADR-0034 now name both preconditions — the header, and not multiplexing sessions over one connection. That is a materially larger client-contract ask than the header alone, and the finding is where it has to live.
Also X-SH-Session → X-SH-Session-Id throughout, per your nit.
|
|
||
| **One basis, both decisions.** The tool-call rate and the sandbox-container count come from the **same | ||
| row of §2.3's table**, named in the run record. Default: the **E6 basis** — calibrate the stub to | ||
| `duty = 0.06–0.08` and provision at `12–24:1` — because `EXPERIMENTS.md:65` supersedes the 29–48:1 |
There was a problem hiding this comment.
suggestion — this default pairs the OCP duty with the two-cluster ratio, which is the blend §2.3 defines a test for.
§2.3 splits the E6 basis into two rows, correctly:
| Duty | Implied N | |
|---|---|---|
| E6 / OCP | 0.06–0.08 | 12–17:1 |
| E6 / Kind | ~0.04–0.05 | ~20–24:1 |
and then states the check: "The pairs are internally consistent because N ≈ 1/duty; a pair that fails that check has been blended from two rows."
This line says the two decisions come from "the same row of §2.3's table", then names duty = 0.06–0.08 (OCP) with 12–24:1 (OCP ∪ Kind). 1/0.06 = 16.7, not 24 — so the stated default is exactly the pair its own invariant rejects. The residual error is ~1.9×, smaller than the 2.4× the section was written to kill, but the same kind.
The sharper argument is that it contradicts the assertion three lines below. Provisioning at 24:1 gives K = ceil(W × S / 24), while K ≥ ceil(W × S × 0.08) = ceil(W × S / 12.5). A run configured from the default as written, at the top of its stated range, fails the driver assertion the same section defines — so the guard is right and the prose is what's wrong, which is the good direction to have the bug but still worth fixing.
Suggest naming the row rather than the experiment: default to E6/OCP — duty = 0.06–0.08, provision at 12–17:1 (also the conservative row, which is the direction §3.1a's process churn argues for), with E6/Kind's 0.04–0.05 / 20–24:1 available whole as a second point, exactly as E7's row already is.
The same union appears in three other places, all reading 6–8% alongside 12–24:1:
- line 179 (§3.1) — "the provisioning ratio of the duty basis §5.4 pins (E6's, so 12–24:1)"
- line 684 (§9 references) — "E6's 6–8% / 12–24:1"
docs/adrs/0034-vm-process-manager-socket-handoff.md:27-28— "duty at 6–8% … (N ≈ 12–24:1, cluster-dependent)"
The ADR's "cluster-dependent" is honest about why the range is wide, so it may be fine as a summary; the two spec sites feed decisions and are worth pinning to one row.
(For the record, the derivations all check out against main: OCP execMs 455–518 over wallMs 6551–7108 gives 0.061–0.079 and N 12.6–16.5 (EXPERIMENTS.md:88-94); Kind 277–290 over 5711–6648 gives 0.042–0.049 and N 19.7–24.0 (:76-80); E7 is 0.021/0.035 → 47.6/28.6 (:121, :161). And :65 does say what the commit message says it says — you were right to provision from E6 and I was wrong to suggest E7's pairing.)
There was a problem hiding this comment.
Fixed in 9f87f11, and the fix was to make the unit explicit rather than just swap the numbers: a basis is a row, not an experiment.
You're right on all three counts — the pairing fails §2.3's own invariant, it fails the driver assertion three lines below (24:1 → K = ceil(W × S / 24) against a guard demanding ceil(W × S / 12.5)), and the guard catching the prose is the better direction for that error to run. My §2.3 table had already split E6 into two rows; §5.4 then reached back up and named the experiment, re-blending what the table had separated. That is the same mistake one level up from the one the section was written to kill, which is why naming the granularity was the fix rather than editing the range.
§2.3 now has three peer rows with per-row cites and the exact per-row derivations you verified — E6/OCP 0.061–0.079 / 12.6–16.5 (:88-94), E6/Kind 0.042–0.051 / 19.7–24.0 (:76-80), E7 0.021/0.035 / 28.6–47.6 (:121, :161) — replacing the rounded ranges, since a section about exact pairing should not round. §5.4 defaults to E6/OCP with your reasoning (conservative row, the direction §3.1a's churn argues for) and keeps the other two as whole sweep points, and it now records the ~1.9× blend as the pinned-out failure so the next reader sees why the row is named.
All three other sites are pinned to the row: §3.1 (E6/OCP, so 12.6–16.5), §10's reference (all three rows enumerated), and the ADR Context — I took the ADR's precise route rather than keeping "cluster-dependent" as a summary, because it also states the rule (a basis is a row, not an experiment) and a summary that spans two rows would undercut the rule in the same paragraph.
| (`JSON.parse(body)`, then `const { sessionId, prompt } = parsed`), and `/turn` is matched on method plus | ||
| **exact URL equality** (`:564`, `req.method === 'POST' && req.url === '/turn'`), so no path segment or | ||
| query string carries it either — and adding `?sid=` would not even route without touching that match. | ||
| So `stickyBySession` reads **`X-SH-Session`**, which the E8 sweep driver sets, and which travels in the |
There was a problem hiding this comment.
nit — X-SH-Session collides semantically with MU1's "session token".
MU1 §3.5 claims Authorization: Bearer <session token> for caller auth, and P5 §3.2 reserves X-SH-Subject for whose work it is. Adding X-SH-Session for a Pi conversation id puts two different meanings of session in one header family — one a credential, one a resumable-state identifier — and the credential one is the one an implementer will meet first, since it's the header that gates the request.
X-SH-Session-Id costs one word now. It stops being free the moment the E8 driver, the sticky policy, and the client-contract ask in §3.4 all reference the shorter name.
There was a problem hiding this comment.
Renamed to X-SH-Session-Id in 9f87f11.
Verified the collision: MU1's header table (:249-252, in §3.5) claims Authorization: Bearer <session token> for may this caller use the harness against P5's X-SH-Subject for whose work this is — and MU1 §5.2 owns the token. Your point about which meaning arrives first is the operative one, so §3.4 records it: the credential is the header that gates the request, so it is the sense an implementer meets first, and one word now is cheaper than the E8 driver, the policy and §3.4's client-contract ask all carrying the shorter name.
Recorded rather than silently renamed, so the -Id does not get trimmed back later by someone who sees only redundancy in it.
…asis to one row Addresses the second review pass on rossoctl#244. **must-fix — sticky's key was per-request but its affinity is per-connection.** The header fix closed the previous finding one layer up; this is the layer below. §3.2 has the supervisor inspect a connection once, at accept time, and then give the socket away, so every later request on that keep-alive connection reaches the worker the *first* id selected — a second session id on it is neither seen nor re-routable. The spec already depended on that fact twice (§3.4 argues against connection-level round-robin because drivers reuse keep-alive connections; §3.9's over-admission row *is* a second turn on a handed-off socket); only sticky's dependence on it was unstated. Left implicit it would have cost the arm: Node's `http.globalAgent` and `undici` pool by default, so a pooling driver degenerates sticky toward whatever its pool does, prices warmth at ~0, and reads as "affinity isn't worth it" with nothing in the data separating that from a real null result. Now closed three ways, as the review suggested: §3.4 states affinity is connection-scoped and requires one connection per session for the sticky arm; §5.2 records connections-per-session per rung beside the duty basis; §7's unit test asserts a second request on the same socket is *not* re-routed even with a different id. §3.4's price paragraph and the ADR now name both preconditions — the header *and* not multiplexing sessions over one connection. **The E6 default paired OCP duty with the two-cluster ratio** — `0.06–0.08` with `12–24:1`, which fails the `N ≈ 1/duty` check §2.3 defines and the driver assertion three lines below it (`24:1` gives `K = ceil(W × S / 24)`; the guard demands `ceil(W × S / 12.5)`). ~1.9×, same kind as the 2.4× the section exists to kill. Fixed by making the unit explicit: **a basis is a row, not an experiment.** §2.3 now carries three rows with per-row cites — E6/OCP `0.061–0.079` / `12.6–16.5` (`:88-94`), E6/Kind `0.042–0.051` / `19.7–24.0` (`:76-80`), E7 `0.021`/`0.035` / `28.6–47.6` (`:121`, `:161`) — and §5.4 defaults to **E6/OCP**, the conservative row. The three other sites carrying the union (§3.1, §10's reference, ADR Context) are pinned to the same row. **nit — `X-SH-Session` → `X-SH-Session-Id`.** MU1's header table (§3.5) claims `Authorization: Bearer <session token>` for caller auth, so the bare name put a credential and a resumable-state identifier under one word, with the credential being the meaning an implementer meets first. §3.4 records the reason so the longer name is not shortened back later. Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
|
Pushed The connection-scoping catch was the right one to block on, and its most useful part was the third bullet. I had stated sticky's price as "a production client would have to start sending the header"; that is necessary but not sufficient, since it would also have to stop multiplexing sessions over one connection. §3.4 and ADR-0034 now name both preconditions. The mechanism itself is stated as a derivation from §3.2 rather than as a caveat — the supervisor inspects a connection once and then holds nothing, so there is no version of hand-off in which sticky is per-request — plus the driver requirement (one connection per session), a The §5.4 blend was the same error one level up from the one §2.3 was written to kill, so the fix was to make the granularity explicit rather than edit a range: a basis is a row, not an experiment. §2.3 now carries three peer rows with per-row cites and the exact derivations — E6/OCP Also Noted on the P5 amendments landing on |
cwiklik
left a comment
There was a problem hiding this comment.
Third pass. All three findings from 5155465832 are closed, and I re-derived the numbers against EXPERIMENTS.md on main rather than taking the new table's word for it.
must-fix — connection-scoped affinity. Closed, and §3.4 now derives it from §3.2 rather than asserting it (:284-291), which is better than what I asked for: stating it as a consequence of hand-off makes clear there is no version of this architecture where sticky is per-request, so nobody later "fixes" it into a body parse. All three closures landed — the driver requirement with the http.globalAgent/undici reason (:293-299), the §5.2 run-record field (:519-523), and §7's assertion that a second request on the same socket is not re-routed even with a different id (:625-628). The ADR now names both preconditions rather than just the header (:64).
suggestion — the duty basis. Making the unit explicit was a better fix than swapping numbers, and you were right to land on E6/OCP against my suggestion of E6-generally, on the strength of EXPERIMENTS.md:65. Every row checks out, and checks out in a way that makes the invariant self-enforcing: each (duty, N) pair is exactly 1/N from the document's own N column, so N ≈ 1/duty now holds by construction rather than by arithmetic that could drift.
- E6/OCP
0.061–0.079/12.6–16.5—1/16.5 = 0.0606,1/12.6 = 0.0794; N values are the table's own 14.8 / 12.6 / 16.5. Cite:88-94correct (rows:90-92, summary:94). - E6/Kind
0.042–0.051/19.7–24.0—1/24.0 = 0.0417,1/19.7 = 0.0508. Cite:76-80correct. - E7
0.021/0.035/28.6–47.6—:121gives 0.021 → N ≈ 48:1 over a 13.9 s leaf wall,:161gives 0.035 → N ≈ 29:1,:135gives the 28.6 explicitly.
The derived claims hold too: ~3.8× end to end is 47.6/12.6 = 3.78, and 1.9× across E6's two clusters alone is 24.0/12.6 = 1.90. The union is scrubbed from all four sites I flagged — §3.1 :179, §5.4, §10's reference :721, ADR Context. The surviving 0.06–0.08 / 12–24:1 at :579 is the passage naming the old error, which is where it belongs; the surviving 29–48:1 mentions are either the "superseded" framing or pre-existing docs this PR does not touch (0022, the E6 spec).
nit — the header name. Renamed at all five sites including the ADR (:64) and §7 (:625); the one surviving bare X-SH-Session at :274 is the sentence explaining why the bare name was rejected, which is the right place for it. Same discipline held on the earlier SH_TURNS_PER_WORKER rename — the lone SH_SESSIONS_PER_WORKER at :363 is the "why not this name" line.
Approving. One suggestion, which is the last mile of the first finding rather than a new direction: the driver requirement pins the sticky arm but not the default one, so the two arms now differ in driver configuration as well as in policy. I would not hold the PR for it — the spec is sound to build from, and it is a protocol detail the E8 driver has to settle regardless. Plus one nit on a wall-clock range that is itself blended across rows.
Also verified: CI 12/12 green on 9f87f11 (lint included, so prettier is satisfied), DCO passing with sign-off on all four commits, docs(spec): title prefix valid, ## Summary present, docs-only diff with no .claude/ or .vscode/ paths, and no secrets.
| §3.9's over-admission row is a second turn on an already-handed-off socket — so what was missing was | ||
| sticky's dependence on it, not the fact. | ||
|
|
||
| That makes a driver requirement, not a caveat: **the sticky arm runs one connection per session** (a |
There was a problem hiding this comment.
suggestion — this closes sticky's measurement gap, but it pins only one of the two arms, which reintroduces the cost asymmetry this section rejects the body-parse alternative for avoiding.
The derivation at :284 is not sticky-specific. leastInFlight also decides once, at accept, and the supervisor also holds nothing afterwards — so every later request on a kept-alive socket lands on the worker chosen for the first one, however loaded it has since become. Two things follow.
1. §3.4's opening argument now over-claims. :254 rejects connection-level round-robin because it "distributes badly the moment a driver reuses keep-alive connections," and :288 cites that sentence as the document already knowing the fact. But under hand-off that sentence describes the architecture, not round-robin — the default inherits it. Least-in-flight is still better, and for a reason worth stating: at each accept it sees true load, so a pool of C sockets created one at a time gets a roughly even static partition across W workers, where round-robin's is positional. What it does not have is immunity — it has a staleness that grows with reuse, and skews with turn-cost heterogeneity, because nothing rebalances a socket once given away. "Strictly better for the same cost" reads as immunity. One clause — least-in-flight is decided per connection too; what it buys is that each connection's initial placement is load-aware — would keep the comparison honest and stop the next reader concluding routing is per-request.
2. The arms differ in driver configuration, not only in policy. §5.2 records connections-per-session on sticky rungs only (:520). So the sticky arm runs one connection per session while the default arm pools — meaning sticky pays one accept + hand-off per session and holds an idle socket for each session's life, where the default pays roughly one per peak-concurrency slot and reuses across sessions. On a 200-session rung at peak concurrency 50, that is ~200 accepts and 200 open sockets against ~50 and ~50.
That cost lands on exactly the side this section says must stay free. :279 rejects the body parse because it turns "sticky's supervisor cost into a term E8 would have to measure rather than one that is free" — one connection per session is a smaller version of the same thing. It matters here specifically because §2.4 already predicts the benefit is small (~900-byte session log against a megabyte config bundle). A small benefit netted against a small unmatched cost can flip sign on the confound, which is the "reads as 'affinity isn't worth it' with nothing in the data separating that from a genuine null result" hazard arriving from the other direction.
Cheapest fix consistent with what landed: make connections-per-session a field on every rung rather than sticky rungs only, and state that both arms of a comparison must carry the same value — i.e. run the default arm one-connection-per-session too, so the only difference between arms is which worker gets chosen. That also gives the default arm's rungs the one driver property determining how much of its load balancing actually happened, which §5.2 currently cannot reconstruct after the fact: a default rung whose driver pooled heavily measured a static partition, not least-in-flight, and nothing in the record distinguishes the two.
Non-blocking, and I would not hold the PR for it — the spec is sound to build from as it stands, and this is a protocol detail the E8 driver has to settle anyway. Worth a sentence here so it is a decision rather than something discovered at rung time.
There was a problem hiding this comment.
Both parts taken in de7c0a9, despite the non-blocking framing — part 1 is a live over-claim in the document and part 2 would have cost the comparison, so neither is really a rung-time detail.
Part 1. You're right that I derived the property for sticky and then left the default's paragraph asserting immunity from it. Worse, :254's reason for rejecting round-robin — "distributes badly the moment a driver reuses keep-alive connections" — is, under hand-off, a description of the architecture rather than of round-robin, so I was citing the default's inherited constraint as evidence for the default. §3.4 now states the distinction your clause names: least-in-flight buys load-aware initial placement where round-robin's is positional (C sockets opened one at a time get an even partition reflecting real load, not arrival order), and explicitly does not buy immunity from reuse — staleness grows with reuse, skews with turn-cost heterogeneity, and nothing rebalances a socket once given away. "Strictly better for the same cost" is gone; "better default, not a per-request balancer" replaces it.
Part 2 is the one I'm glad you pushed on, because the asymmetry is the same shape as the thing §3.4 had just finished rejecting. Your accounting is the argument: ≈200 accepts and 200 held sockets against ≈50 and ≈50 is an unmatched cost on the arm §2.4 already predicts has a small benefit, and a small benefit netted against a small unmatched cost can flip sign — the identical confound to the pooled-driver one, arriving from the other side. So both arms of a comparison run the same connections-per-session, framed as §5.3's E9 invariant applied one tier up: vary one thing, pin the rest. That framing is what makes it a decision rather than a driver knob.
Your closing observation drove the §5.2 change more than the fairness argument did: a default rung whose driver pooled heavily measured a static load-aware partition, not least-in-flight in motion, and nothing in the record distinguished those. So the field is recorded on every rung with the two reasons stated separately — sticky's affinity hit-rate, and the default's how-much-balancing-actually-happened — plus the note that a mismatch between arms charges one policy for the other's connection budget. ADR-0034 carries the both-arms pin.
| blended from two rows. **P6 provisions from E6/OCP**, for a reason the tree states rather than one we | ||
| prefer: `EXPERIMENTS.md:65` records that E6's real-converge finding **supersedes "the earlier single-N | ||
| figure (N ≈ 29–48:1), which used a trivial `marker.txt` leaf with no real converge"** — and E7's | ||
| numerically identical 29–48:1 comes from a 13.9 s leaf wall against E6's ~6.1–7.1 s, i.e. a lighter |
There was a problem hiding this comment.
nit — this wall-clock range is itself blended across E6's two rows, in the sentence that establishes rows as the unit.
~6.1–7.1 s matches neither row: E6/Kind is 5711–6648 ms (:76-78) and E6/OCP is 6551–7108 ms (:90-92). 6.1 is Kind's L0 (6095) and 7.1 is OCP's L2 (7108), so the range spans the row boundary the paragraph is drawing.
Nothing downstream depends on it — it is a descriptive comparison against E7's leaf wall, not a (duty, N) pair, so no invariant fails. It is just one number away from being an instance of the thing the section exists to prevent. ~6.6–7.1 s (E6/OCP, the row the spec provisions from, which is also the sharper comparison since that is the basis §5.4 pins) or ~5.7–7.1 s (both E6 rows, said to be both) would each do.
Verified while I was in there: 13.9 s is E7/Kind (:121) and E7/OCP is ~14 s (:161), so the single figure is fine for the comparison being made.
There was a problem hiding this comment.
Fixed in de7c0a9 — now E6/OCP's ~6.6–7.1 s (6551–7108 ms), the row §5.4 pins.
Took the OCP option for the reason you give: it is the sharper comparison, since it is the basis the design provisions from, and it keeps the paragraph that establishes rows as the unit from containing a cross-row range. Confirmed 6.1 was Kind's L0 (6095) against 7.1 as OCP's L2 (7108) — a blend I introduced in the same edit that split the rows apart, which is a good illustration of why the invariant had to be made checkable rather than just stated.
… sticky Addresses the third review pass on rossoctl#244 (approved, non-blocking). **The derivation was sticky-specific but the property is not.** `leastInFlight` also decides once, at accept, and the supervisor also holds nothing afterwards — so §3.4's opening over-claimed. It rejected connection-level round-robin because "a driver reuses keep-alive connections", which under hand-off describes the architecture rather than round-robin; the default inherits it, and "strictly better for the same cost" read as immunity. §3.4 now states what least-in-flight actually buys: each connection's **initial placement is load-aware** where round-robin's is positional, so a pool of C sockets opened one at a time gets an even partition reflecting real load. What it does not buy is immunity from reuse — staleness grows with reuse and skews with turn-cost heterogeneity, since nothing rebalances a socket once given away. Better default, not a per-request balancer. **The one-connection-per-session pin covered one arm, which is the asymmetry this section rejects the body parse for creating.** Sticky would pay one accept plus hand-off per *session* and hold an idle socket for each session's life, where a pooling default arm pays roughly one per peak-concurrency slot — ≈200 accepts and 200 sockets against ≈50 and ≈50 on a 200-session rung peaking at 50. Charged to sticky alone that is an unmatched cost on the arm §2.4 already predicts has a *small* benefit, and a small benefit netted against a small unmatched cost can flip sign — the same confound arriving from the other direction. So **both arms run the same connections-per-session**, which is §5.3's E9 discipline one tier up: vary one thing, pin the rest. **§5.2 records the field on every rung, not just sticky ones**, and each arm needs it for its own reason: a pooled sticky rung priced warmth at whatever the pool did; a pooled default rung measured a static load-aware partition rather than least-in-flight in motion. Neither is recoverable after the fact without the field, and a mismatch between arms charges one policy for the other's connection budget. ADR-0034 carries the both-arms pin. **nit** — E7's leaf-wall comparison cited `~6.1–7.1 s`, which blends Kind's L0 (6095 ms) with OCP's L2 (7108 ms) in the very paragraph establishing rows as the unit. Now `E6/OCP's ~6.6–7.1 s` (6551–7108 ms), the row §5.4 pins. Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
|
Pushed §3.4's opening asserted immunity the architecture does not grant. The one-connection-per-session pin covered one arm, which is the asymmetry §3.4 had just rejected the body parse for creating. ≈200 accepts and 200 held sockets against ≈50 and ≈50 is an unmatched cost on the arm §2.4 already predicts has a small benefit — and a small benefit netted against a small unmatched cost can flip sign, the same confound from the other direction. Both arms now run the same connections-per-session, framed as §5.3's E9 invariant one tier up: vary one thing, pin the rest. §5.2 records the field on every rung, with each arm's reason stated separately — the review's sharpest point being that a pooled default rung measured a static load-aware partition rather than least-in-flight in motion, and nothing in the record told those apart. ADR-0034 carries the pin. And the nit: Thanks for the three passes — each one found the layer below the last, and the numbers in §2.3 are now self-enforcing because of it. |
Design-only. Adds the P6 spec and ADR-0034, and registers both. No code, no manifests.
Realizes the deployment-model slice that P5 §6 and ADR-0032 explicitly defer —
ScaledJob→ elastic pool, and #55's overload shift from pod-level to session-level — on a non-Kubernetes substrate, with two experiments as the deliverable: E8 (density/saturation on one VM) and E9 (deployment-tier comparison against Knative, model tier held constant).The premise
Scale-to-zero and high density are substitutes, not complements. Once one process holds N sessions the deployment never idles at zero, so Knative's autoscaler stops earning the cold start it charges. That is not speculative:
EXPERIMENTS.md:96already attributes E6's p95 blowup under concurrency to "LLM latency + Knative cold-start … not the sandbox".What tracing the tree at
c12a97cfoundMost of the stack is already substrate-neutral — the
SandboxTransportgRPC path (whose Go worker dials out and needs no inbound route), Redis presence and leases, session state, the queue, leaf results, and a plainnode:httpsurface with no Knative coupling insrc. MU1 already put credentials behindCredentialStore. The volume-envelope PVC turns out to be documentation-era; results are in Redis.Two existing measurements decided the design rather than decorating it:
One code-level blocker:
select-sandbox.ts:87lists pods viakubectlunconditionally, before the gRPC branch — so the presence path cannot run on a host withoutkubectldespite needing nothing from it. Fixed by an explicitSH_SANDBOX_DISCOVERYsource selector, not by swallowingENOENT, which would turn a brokenkubectlon the cluster path into a silently empty pool.The load-bearing decision
The supervisor hands off accepted sockets over IPC and never touches a response byte. A byte-proxying supervisor would put its own event loop inside the very ceiling E8 exists to find, so a knee could be the supervisor's with no way to tell. Hand-off removes that confound structurally — and settles the supervisor's language as Node, since there are no bytes on its hot path.
The honesty crux
Because routing is stateless, an idle session costs ~900 bytes, so "N concurrent sessions" could be inflated arbitrarily. The spec fixes the vocabulary before any driver exists: concurrent in-flight turns (what the knee applies to) versus sessions addressable (a Redis capacity statement, not a density claim).
Scope discipline
Round one claims density and scalability only. Cost accounting is round two (E9 compares capacity, not cost). Isolation-at-density is inherited from P5 and tested, but not claimed — which is what keeps Firecracker/gVisor/Kata out of this slice and in P4 (#57), where it already lives. Single-host, vertical only: no multi-VM placement, which is where the "we rebuilt Kubernetes, worse" risk sits.
The Kubernetes path is preserved and used, not merely kept — it supplies E9's comparison arm.
startServer()is untouched (the only change ishandlergaining anexport), and two regression pins assert the cluster path is unchanged.One cross-track ask
§3.6: P5's credential scrub should land as a shared function both entry points call rather than inline in
server.ts, since the VM worker is a third entry point with the same multiplexing exposure. Coordinated with that track, not duplicated here. §3.10 states precisely what P5 gates — mixed-subject rungs and the isolation claim, not the work, which is buildable and measurable single-subject first.Written to be plannable from cold
A fresh planning session should be able to start from the spec + ADR alone: §3.8 names the configuration surface, §3.9 states the supervisor↔worker IPC contract completely (including the bounded over-admission it accepts and why closing it would repeat §3.2's mistake), §3.10 gives ordered steps with a provable-by column, and §9 fixes the file layout, driver homes, and
detectKnee'sc === 1baseline requirement instead of leaving them to be chosen.Every line-number citation was verified against the tip rather than carried over from a stale base.
🤖 Generated with Claude Code