Skip to content

feat: sandbox tiers and session-to-sandbox affinity — harness, relay, workers (P6.3 PR 1, #425) - #442

Merged
pdettori merged 21 commits into
rossoctl:mainfrom
pdettori:feat/425-slice3-pr1
Oct 5, 2026
Merged

pdettori merged 21 commits into
rossoctl:mainfrom
pdettori:feat/425-slice3-pr1

Conversation

@pdettori

@pdettori pdettori commented Oct 5, 2026

Copy link
Copy Markdown
Member

Summary

PR 1 of 3 for #425 (P6.3, sandbox tiers and session-to-sandbox affinity).

  • Workers advertise their sandbox tier as the moca.dev/tier presence label.
  • The relay records when each sandbox leaves (its detach mark).
  • selectPoolSandbox keeps a session in its tier and on the sandbox that served its previous turn. It answers 503 while that sandbox is busy or briefly gone. Past a grace period, it moves the session within its tier and records a workspace reset.
  • A turn reports where it ran, and streams a workspace_reset frame when the session lands on a fresh workspace. mocactl displays it.

Spec: docs/specs/2026-10-04-p6-on-kubernetes-slice3-design.md (v1.2; §0.1 and §0.2 list the corrections made during implementation).

What changed

  1. Workers advertise their tier. worker and microvm-worker read SANDBOX_TIER (defaults container and microvm; set but empty sends no label) into Hello.labels["moca.dev/tier"]. No proto change.
  2. Tier configuration and the affinity store (harness).
    • SH_SANDBOX_TIERS / SH_SANDBOX_DEFAULT_TIER are parsed and validated. The parser is pinned by a shared JSON fixture, which the control plane will reuse in PR 2.
    • New settings SH_SANDBOX_AFFINITY_TTL_SECONDS (default 86400) and SH_SANDBOX_AFFINITY_GRACE_SECONDS (default 60).
    • RedisAffinityStore keeps sh:sandbox:affinity:<sessionId> and sh:sandbox:detached:<sandboxId>. Its claim and detach-mark Lua scripts are atomic, and garbage or future marks are replaced by "now".
  3. The relay's detach mark. Teardown overwrites the mark after the presence remove, and Hello clears it before the presence put. Both go through the same Redis client, so their order holds.
  4. Tier filtering.
    • Only records in the session's tier are candidates. Pods count as the default tier.
    • An unlabelled or mislabelled worker is excluded, with one log line per sandbox id.
    • A new SandboxAffinityPendingError subclasses SandboxPoolSaturatedError, so it is a retryable 503 on /turn and "saturated" on leaves.
  5. Session-to-sandbox affinity.
    • Busy: if the session's sandbox is at capacity, the turn gets a 503 naming it.
    • Absent: the turn gets a 503 "pending" for the grace period. This also covers a relay restart, which wipes every record.
    • Gone past the grace, or moved to another declared tier: the session falls back to the least-loaded sandbox in its tier, and the reset is recorded.
    • Sandbox record from another tier (including those written before tiers were on): honoured if its sandbox is in the session's tier.
    • Concurrent first turns: they converge on one sandbox through a compare-and-claim.
    • Leases: every acquired lease is returned or released on every path.
  6. Placement and workspace_reset.
    • The tier is threaded through /turn as TurnConfig.sandboxTier. PR 2 sets it from the control plane's exchange.
    • A turn result carries sandbox: { id, tier, workspaceReset? }, and the runtime report gets sandboxId / sandboxTier / workspaceResetAt / workspaceResetFrom at turn end.
    • The workspace_reset frame goes out before any model output. mocactl shows it as a warning notice; older clients ignore unknown frames.
  7. End-to-end test (packages/sandbox-relay/test/tier-affinity.integration.test.ts). It uses real Redis and a real relay, with two tiers of two fake workers each. It covers:
    • tier pinning;
    • an exact least-loaded spread;
    • affinity across turns;
    • surviving a relay restart;
    • the grace-period wait;
    • the in-tier fallback, and the TTL on the relay's mark.

Compatibility

  • With SH_SANDBOX_TIERS unset nothing is filtered. Affinity alone applies, which fixes the container-tier workspace hop (two container sandboxes, with turn 2 landing on the other one).
  • The pods-only path is unchanged: no affinity store is built or called.
  • Error messages without a tier are byte-for-byte unchanged.

Follow-ups (not in this PR)

  • PR 2: the control plane records each session's tier and returns it on the exchange; mocactl gets a tier option; the resources view shows placement. It will also record a reset when the fallback turn itself fails.
  • PR 3: deployment wiring and startup validation of SH_SANDBOX_TIERS.
  • Separate issue: two concurrent turns of one session that both outlast the grace period can each move it. The workspace has already been reset by then; a compare-and-set replace would close this.

Test results (rebased on upstream/main fc021ba, which includes #436 and #437)

make lint                          EXIT:0
make typecheck                     EXIT:0
make test                          EXIT:0   (harness 575 passed, sandbox-relay 66, knative-server 423, mocactl 559, control-plane 453, supervisor 187, …)
cd remote-worker && go test ./...  EXIT:0

Part of #425.

🤖 Generated with Claude Code

…ctl#425)

Slice 3 of the P6-on-Kubernetes epic (rossoctl#426). A session picks an
operator-defined tier at creation; every turn stays in that tier and on
the sandbox that served the previous turn, with a 503 grace period and a
recorded in-tier fallback when that sandbox is gone.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
…ctl#425)

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
…/tier (rossoctl#425)

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
…ossoctl#425)

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
…orrupt entry test spy

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
…nd cleared on Hello (rossoctl#425)

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
rossoctl#425)

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
…ossoctl#425)

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
… inventory (rossoctl#425)

Review round 1 of Task 4: the empty-tier message no longer reuses the pods/records wording, the tier tests reset the unlabelled-warning memo, the discovery=both test fails without the filter, and the run-turn re-export comment covers all three errors.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
…n in-tier fallback (rossoctl#425)

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
…and for an empty tier (rossoctl#425)

An affine record that is present but unlabelled is not 'another tier' (spec §4 step 4): it now takes the detach/grace path instead of an immediate 'retiered' reset. On the records path the empty-candidates check moves after the affinity read (spec §5, relay restart), so a session with affinity gets SandboxAffinityPendingError and starts its grace clock; the pods-only path keeps its early check. The reported pending wait is clamped to the grace.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
rossoctl#425)

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
… an empty tier (rossoctl#425)

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
…ossoctl#425)

Real Redis, a real relay, two tiers by two fake workers: tier pinning, affinity across turns, relay-restart survival, the grace-period wait, and the in-tier fallback with its workspace reset. Also pins that the relay's detach mark carries the affinity TTL. Adds redis as a sandbox-relay devDependency for the raw PTTL read.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
…unexpected warnings pass through (rossoctl#425)

Assert 5/5 per tier sandbox (first-fit would give 8/2 and still pass the old used.size check), reword the turns 2-3 comment to say affinity is proven against the stable tie-break, and silence only 'sandbox affinity:' warnings.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
…belled worker; replace a future detach mark (rossoctl#425)

Final-review items 1-3 of PR 1 (P6.3):

- Any affinity entry goes through the affine branch. If its sandbox is in
  the session's (tier-filtered) candidates it is taken and re-recorded
  under the session's tier (claim when the tier matches, replace when it
  does not), with no workspace reset. Previously an entry recorded under
  '' was ignored once tiers were switched on, losing the workspace with
  no frame, field or log.
- A record labelled with a tier that is not in SH_SANDBOX_TIERS is
  excluded with one warning per id naming its labels, like an unlabelled
  one, and is not counted as "retiered": only a label that is a declared
  tier other than the session's resets at once; the rest take the grace.
- DETACHED_SINCE_LUA keeps a mark only if it is an integer at or before
  now; a future or huge mark is replaced by now, so the grace runs from
  now instead of stalling the session until the key's TTL.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
…and selection (rossoctl#425)

Final-review item 4: store method names (get, claim, replace,
detachedSince), the relay's plain-SET detach write, an entry from
another tier honoured and re-recorded, "retiered" only for another
declared tier, the narrowed concurrency claim, the relay-restart and
mislabelled-worker rows of §5, and a v1.2 list covering the §6
JSON-result amendment and this wave.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
Comment thread harness/src/sandbox-affinity.ts Fixed
Comment thread harness/src/select-sandbox.ts Fixed
Comment thread harness/src/select-sandbox.ts Fixed
Comment thread harness/src/select-sandbox.ts Fixed

@pdettori pdettori left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Reviewed P6.3 PR 1 — sandbox tiers and session-to-sandbox affinity (harness, relay, workers, mocactl).

Checked: tier parsing/validation (parseSandboxTiers), the Redis affinity store (atomic claim/detach-mark Lua, corrupt-value and clock-skew handling), tier filtering and the affinity state machine in selectPoolSandbox (busy → 503, absent-within-grace → pending 503, past grace / retiered → in-tier fallback with workspace_reset), first-turn claim-race convergence with single re-run, lease release on every path, relay detach-mark ordering through the shared FIFO client, Go TierLabels (incl. set-but-empty semantics), knative-server 503 mapping and runtime-report fields, mocactl frame handling, and the test suite — including the real-Redis/real-relay integration test. Dependency addition (redis devDep in sandbox-relay) is expected for that test.

No blockers found. Two notes:

  • check and k8s-kind-e2e were still pending at review time — confirm green before merge.
  • Nit: the title runs ~95 chars, past the 72-char advisory.

(Posting as a comment — GitHub won't let the author's own token submit an approval; a second maintainer can approve.)

'SandboxPoolSaturatedError',
'SandboxPoolEmptyError',
// P6.3: the session's own sandbox is briefly absent. Same advice: retry after Retry-After.
'SandboxAffinityPendingError',

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

suggestion — related to this mapping: turnErrorHeaders (below) advertises the global saturation Retry-After (default 5s) for SandboxAffinityPendingError too, so a session waiting out its (default 60s) grace polls ~12 times before the grace expires and the fallback runs. The error already carries retryInMs — could turnErrorHeaders take the error and use it (perhaps clamped, e.g. min(retryInMs, 10s)) so a client that honours Retry-After backs off proportionally? If the fixed 5s is deliberate — polling reacts fastest to a sandbox returning early — happy to see this land in PR 2/3 instead.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Done in f485b72 (+ ae287f3): turnErrorHeaders(status, err?) now sets Retry-After for a SandboxAffinityPendingError to min(10, max(1, ceil(retryInMs / 1000))) — proportional to the grace left, capped at 10 s so a sandbox that returns early is still noticed quickly; a non-finite retryInMs falls back to the configured value. Every other 503 keeps KAGENTI_SYNC_SATURATION_RETRY_AFTER_S. Both call sites (sync catch and the SSE pre-first-frame catch) pass the error. Tests in turn-error-status.test.ts.

…ctl#442 CodeQL)

CodeQL js/log-injection alerts on four log lines built from user- or
worker-supplied values (session ids, sandbox ids, labels). Add a forLog()
helper that removes CR, LF and other control characters; wrap all
externally supplied values in those log lines with forLog(...). Detect
by string comparison, as per the precedent in turn-error-status.test.ts.

- sandbox-affinity.ts: forLog() helper, and apply it in RedisAffinityStore.get()
- select-sandbox.ts: import forLog(), apply it in recordInTier(), remember(),
  and the two pending/fallback warnings

Tests:
- forLog() strips \r, \n, \u001b and keeps ordinary text
- select-sandbox-tiers: session id with newline in pending warning has no
  newline in the logged output

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
…grace (rossoctl#442 review)

When a session is waiting for its affine sandbox to return (grace-based), the
global saturation Retry-After (default 5 s) is static while the session's grace
window is narrowing. Reply with the grace-relative interval instead: detect a
SandboxAffinityPendingError by name (not instanceof, following turn-error-status
precedent) and its numeric retryInMs property; set Retry-After to
Math.min(10, Math.max(1, Math.ceil(retryInMs / 1000))) — proportional backoff
capped at 10 s so a sandbox returning early is still noticed quickly. Every
other 503 keeps the existing configured value; non-503 unchanged.

- server.ts: turnErrorHeaders() now accepts an optional err parameter; detect
  and handle SandboxAffinityPendingError; pass err at both call sites (sync
  and SSE pre-first-frame catch)
- turn-error-status.test.ts: new tests for pending errors with 30s, 4.2s, and
  0 ms retryInMs; plain saturation errors; and non-503 status

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
…ossoctl#442 review)

CodeQL's StringReplaceSanitizer requires explicit \r\n in character class and
no quantifier; change /[\u0000-\u001f\u007f]+/g to /[\r\n\u0000-\u001f\u007f]/g.
Wrap error messages in remember() with forLog() to sanitize externally supplied
error text.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
@pdettori
pdettori merged commit ccc17b0 into rossoctl:main Oct 5, 2026
14 checks passed
@pdettori
pdettori deleted the feat/425-slice3-pr1 branch October 5, 2026 14:42
pdettori added a commit to pdettori/moca that referenced this pull request Oct 7, 2026
…nd 11 claims marked historical (rossoctl#454 review)

The P6.3 spec's Status is Implemented (PRs rossoctl#442, rossoctl#444 and rossoctl#454; accepted on the KVM rig and OpenShift
4.20.8), its two mocactl --tier mentions are --option sandboxTier=..., and the README's P6.3 row is
implemented. The slice-2 spec's 'both tiers together are refused' carries the P6.3 superseded pointer,
and its '11 claims' is the count of slice 2's time (P6.3 adds claim 12).

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants