Skip to content

[PARKED] feat(agents): project scope rides runContext; mount becomes the pool-key fallback#5180

Draft
mmabrouk wants to merge 1 commit into
big-agentsfrom
feat/keepalive-project-scope
Draft

[PARKED] feat(agents): project scope rides runContext; mount becomes the pool-key fallback#5180
mmabrouk wants to merge 1 commit into
big-agentsfrom
feat/keepalive-project-scope

Conversation

@mmabrouk

@mmabrouk mmabrouk commented Jul 9, 2026

Copy link
Copy Markdown
Member

PARKED, do not merge: pending coordination with JP's warm-sessions-to-backend work. This slice touches the session-pool keying and the sandbox_agent engine that JP is relocating. The code is complete, tested, and reviewed as a draft; re-evaluate the landing zone once JP's design settles.

The runner keeps a live agent session parked between turns so the next message in the same
conversation continues the same harness process. Parked sessions are pooled by project scope,
so a session can never be resumed across a project boundary. Decision 1 of the keep-alive
design chose the mount-sign response as the interim source of that scope, because at the time
no trustworthy project id rode the /run wire. The recorded cleanup (Decision 1 option b) was
to stamp a server-derived project id into runContext and prefer it. This PR is that cleanup.

What changed

  • The service now stamps the runs owning project id into runContext.project.id. It reads
    the id from its own request state (the authenticated OTel baggage, the same source the auth
    middleware scopes its permission check on), never from a caller-supplied field. This is the
    same "from the request state, never the request body" discipline RuntimeAuthContext.project_id
    documents.
  • The wire gains an optional runContext.project sub-object. Added deliberately across every
    mirror: the golden fixture, protocol.ts, dtos.py + wire.pys to_wire, the wire-model
    schema, and BOTH contract tests (Python test_wire_contract.py and TS wire-contract.test.ts).
  • The runners pool key now PREFERS runContext.project.id and FALLS BACK to the mount-derived
    scope. If neither source yields a scope, the session never parks (unchanged safety rule).
  • The keep-alive logs tag which scope produced the key: scope=run-context vs scope=mount.
  • Doc comments that called the mount "the only project scope the runner can trust" now describe
    the preference order, and the agent-workflows runtime doc explains where the scope comes from.

Scope and risk

  • The wire addition is optional and omit-when-empty, so a run that carries no project id is
    byte-identical to before. Old requests keep working; the runner just uses the mount fallback.
  • The no-scope-no-park safety invariant is unchanged: a request with no project scope from either
    source still runs fully cold and is never pooled.
  • The caller can never supply the scope. The rejected top-level projectId wire field (which the
    playground never populates and the runner never reads) is left untouched by design.

How to QA

  • Runner: cd services/runner && pnpm test && pnpm typecheck. New poolKeyFor cases cover
    run-context-wins, mount-fallback, and neither-source-never-park. (Two pre-existing failures in
    sandbox-agent-qa-transcript-replay.test.ts are unrelated fixture drift.)
  • SDK: cd sdks/python && uv run pytest oss/tests/pytest/unit/agents/ -q. New test_tracing.py
    cases cover the server-baggage stamp and the project failure domain; the contract test + golden
    pin the wire field. (Two pre-existing failures in test_capabilities.py /
    test_dtos_agent_template.py are unrelated permission-default drift.)
  • Watch the scope source in the keep-alive logs: grep 'scope=' shows scope=run-context
    when the stamp is present and scope=mount on the fallback path.

The Decision 1 / follow-up 5 status rows in the session-keepalive design workspace land after
#5153 merges (that folder only exists on the docs/session-keepalive-plan lane, not in the base).

https://claude.ai/code/session_01AumZJ9xRd4XYNHThqy4rTv

@vercel

vercel Bot commented Jul 9, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
agenta-documentation Error Error Jul 10, 2026 5:33pm

Request Review

@coderabbitai

coderabbitai Bot commented Jul 9, 2026

Copy link
Copy Markdown

Review Change Stack

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 9f7f9191-9aa8-49a8-abbd-8eccbdda5ca8

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This PR introduces a project identity field to run-context DTOs, wire models, and protocol interfaces across the Python SDK and runner service. It threads project.id through tracing capture, to_wire() serialization, and runner keep-alive pooling, making poolKeyFor prefer the run-context-stamped project scope over the mount-derived fallback. Server dispatch logic and type signatures are updated accordingly, with associated tests and documentation.

Changes

Project-scoped keep-alive pooling

Layer / File(s) Summary
RunContext project DTO and wire model
sdks/python/agenta/sdk/agents/dtos.py, sdks/python/agenta/sdk/agents/__init__.py, sdks/python/agenta/sdk/agents/wire_models.py
Adds RunContextProject DTO with optional id, wires it into RunContext.project and to_wire() serialization, exports it publicly, and adds matching WireRunContextProject/WireRunContext.project wire fields.
Tracing capture of project scope
sdks/python/agenta/sdk/agents/tracing.py, sdks/python/oss/tests/pytest/unit/agents/test_tracing.py, test_wire_contract.py, golden/run_request.pi_core.json
Adds _run_context_project() reading project_id from OTel baggage; updates run_context() to capture project as an independent failure domain; extends unit tests and golden fixtures for project stamping and fallback behavior.
Runner poolKeyFor project-scope preference
services/runner/src/protocol.ts, services/runner/src/engines/sandbox_agent/session-pool.ts, services/runner/tests/unit/session-pool.test.ts, wire-contract.test.ts
Adds RunContext.project, introduces PoolScope/PoolScopeSource types, updates poolKeyFor to prefer run-context project id over mount id, returning null when neither is available; expands test coverage.
Server keep-alive dispatch and doc updates
services/runner/src/server.ts, services/runner/src/engines/sandbox_agent.ts, sandbox_agent/mount.ts, session-keepalive-dispatch.test.ts, docs/design/agent-workflows/documentation/running-the-agent.md
Updates KeepaliveEngine.acquireEnvironment to accept undefined presignedMount, adjusts runWithKeepalive/coldAndPark scope and epoch derivation, updates comments/docs, and adds a regression test for parking without a mount when run-context project id is present.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant Server as server.ts (runWithKeepalive)
  participant MountResolver as resolveKeepaliveMount
  participant SessionPool as session-pool.ts (poolKeyFor)
  participant Engine as KeepaliveEngine

  Client->>Server: run request with runContext.project.id
  Server->>MountResolver: resolveKeepaliveMount()
  MountResolver-->>Server: signed (mount or null/undefined)
  Server->>SessionPool: poolKeyFor(request, signed?.projectId)
  SessionPool->>SessionPool: prefer runContext.project.id, else mountProjectId
  SessionPool-->>Server: PoolScope{key, source} or null
  alt PoolScope resolved
    Server->>Engine: acquireEnvironment(session, signal, signed)
    Engine-->>Server: environment
    Server->>SessionPool: park session under PoolScope.key
  else no scope available
    Server->>Engine: run cold (coldAndPark)
  end
  Server-->>Client: run result
Loading

Possibly related PRs

  • Agenta-AI/agenta#4892: Both PRs extend the shared /run runContext wire/DTO models, adding new optional fields to RunContext and its wire serialization.
  • Agenta-AI/agenta#5156: Both PRs modify runner keep-alive session pooling in session-pool.ts, with this PR extending poolKeyFor scope-selection logic built on that foundation.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 30.77% which is insufficient. The required threshold is 60.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly summarizes the main change: runContext project scope is preferred and mount scope is the fallback.
Description check ✅ Passed The description is directly related to the keep-alive pooling and runContext project-scope changes in the PR.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/keepalive-project-scope

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

:class:`~agenta.sdk.agents.connections.models.RuntimeAuthContext.project_id`). The runner uses
it as the preferred project scope for session keep-alive. Best-effort: no baggage / no
``project_id`` returns ``None`` and the field is simply omitted."""
baggage = TracingContext.get().baggage or {}

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.

The stamp seam. The project id is read from the SERVER-derived request state (the authenticated OTel baggage on TracingContext, the same source the auth middleware scopes its permission check on), never from anything the caller sends. Stamping it here keeps the trust boundary at the service; the runner then trusts it because the service is trusted.

}
if run:
out["run"] = run
if self.project is not None:

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.

Omit-when-empty, matching the run/workflow/trace blocks: an unset or empty project serializes to nothing and to_wire drops the key. This is what keeps a run with no stamped project byte-identical on the wire to before this change.

if (!sessionId || !project) return null;
return `${project}:${sessionId}`;
if (!sessionId) return null;
const runContextProject = request.runContext?.project?.id?.trim();

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.

The preference order: the service-stamped runContext.project.id wins over the mount-derived scope. This is the whole point of the change — the trustworthy id no longer depends on a durable mount existing.

}
const mount = mountProjectId?.trim();
if (mount) return { key: `${mount}:${sessionId}`, source: "mount" };
return null;

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.

The fallback + no-park rule. After trying runContext then the mount, if NEITHER yields a scope we return null and the dispatch never parks (there is no safe key that separates callers). This safety invariant is unchanged from before.

assert.equal(req.runContext!.run!.kind, "test");
// The server-stamped owning project scope (F5). The runner prefers `runContext.project.id`
// over the mount-derived scope when keying its keep-alive pool (see `poolKeyFor`).
assert.equal(req.runContext!.project!.id, "proj_abc");

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.

Contract-test pinning (TS consumer side). This plus the Python producer assertion and the shared golden pin the new runContext.project field across both languages, so the two hand-mirrored sides cannot drift silently.

"run": {"kind": "test"},
# The server-stamped project scope (F5), snake_case inner key `project.id` — the
# `$ctx.project.id` binding namespace and the runner's preferred keep-alive pool scope.
"project": {"id": "proj_abc"},

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.

Contract-test pinning (Python producer side), asserted against the same golden the TS test loads. snake_case project.id is deliberate: it is the $ctx.project.id binding namespace, matching the other runContext inner keys.

* only project scope the runner can trust for this request (the /run wire carries no project
* id today), so session keep-alive keys its pool on `<projectId>:<sessionId>`. Absent when the
* response omitted the mount object; keep-alive then refuses to park (no safe key source).
* FALLBACK project scope for session keep-alive: the pool prefers the service-stamped

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.

Audit-driven doc update. The mount audit (Mahmoud's ask) found the mount-sign response was overloaded: beyond durable storage (bucket/prefix/creds) and the legitimate credential-expiry it folds into the credential epoch, its project_id was doing IDENTITY duty as the pool-key scope. That was the one overload; this change demotes it to the fallback and moves the trustworthy identity to runContext. Every other mount consumer is a storage or credential concern.

@mmabrouk mmabrouk changed the title feat(agents): project scope rides runContext; mount becomes the pool-key fallback [PARKED] feat(agents): project scope rides runContext; mount becomes the pool-key fallback Jul 9, 2026
@mmabrouk

mmabrouk commented Jul 9, 2026

Copy link
Copy Markdown
Member Author

Parked on Mahmoud's call: JP is moving warm sessions to the backend, and this slice changes the pool keying that work touches. Keep as draft; do not merge until the landing zone is coordinated with JP. The F2 structural cleanup of sandbox_agent.ts is held entirely for the same reason.

@mmabrouk mmabrouk force-pushed the feat/keepalive-project-scope branch from d5a8aa9 to 1b949d9 Compare July 9, 2026 15:34
@mmabrouk

mmabrouk commented Jul 9, 2026

Copy link
Copy Markdown
Member Author

Review round 1 (independent reviewer): FAIL with one blocking bug and one accuracy nit. Both are fixed and amended into the F5 commit (now 1b949d9).

B1 (blocking): parking without a mount crashed the turn. resolveKeepaliveMount can legitimately return null (store unconfigured, 503). Before this PR that also meant no scope, so the dispatch bailed to cold. With the run-context scope, control passed the guard and dereferenced the null mount (signed!.expiresAt), throwing a TypeError that failed the whole turn. That breaks the project invariant: a keep-alive gap may only ever cost a cold restart, never a failed turn. Fix: the dispatch no longer assumes a mount. The epoch takes signed?.expiresAt (it already tolerates a missing expiry), and the acquire receives the sign result verbatim (null means do not re-sign, undefined means the sign threw and the acquire retries it). A mount-less session now parks normally. New regression test in session-keepalive-dispatch.test.ts: null sign combined with a request carrying runContext.project, asserting the turn succeeds and parks on the run-context scope. The old no-mount test kept masking this because its request carried no runContext.project.

S1 (nit): the docstrings claimed the project id is "never caller-supplied". Textually the id arrives on the caller's W3C baggage header. What makes it trustworthy is authorization gating: the auth middleware scopes its permissions check on that same project_id and denies any request whose credential is not authorized for it, so a forged id cannot cross tenants. The docstrings in tracing.py, dtos.py, wire_models.py, and protocol.ts now say exactly that, so nobody weakens the auth check unaware it backstops the pool key.

Tests: runner 743 passed (same 2 known qa-transcript-replay fixture failures, unrelated), typecheck clean. Python 598 passed (same 2 known pre-existing failures, unrelated), ruff clean.

// restart, never a failed turn.
const cfgFp = configFingerprint(request);
const incomingEpoch = computeCredentialEpoch(request, mountCreds.expiresAt);
const incomingEpoch = computeCredentialEpoch(request, signed?.expiresAt);

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.

The B1 null-handling seam. With the run-context scope, a request can reach this point with no mount at all (store unconfigured, 503, or the sign threw). Parking mount-less must not fail the turn: the keep-alive invariant is that any gap here may only cost a cold restart. So the epoch takes signed?.expiresAt (no mount means no mount-expiry eviction, TTL and rotation-hash still bound the park), and the acquire receives the sign result verbatim, preserving the null vs undefined distinction (do-not-re-sign vs retry-the-sign). The dispatch regression test pins the null-sign + run-context-scope combination.

@mmabrouk mmabrouk changed the base branch from fix/keepalive-approval-ttl-default to big-agents July 9, 2026 15:47

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: ad50b7ca-f6f9-4514-98aa-6afab12a5275

📥 Commits

Reviewing files that changed from the base of the PR and between 6570670 and 1b949d9.

📒 Files selected for processing (16)
  • docs/design/agent-workflows/documentation/running-the-agent.md
  • sdks/python/agenta/sdk/agents/__init__.py
  • sdks/python/agenta/sdk/agents/dtos.py
  • sdks/python/agenta/sdk/agents/tracing.py
  • sdks/python/agenta/sdk/agents/wire_models.py
  • sdks/python/oss/tests/pytest/unit/agents/golden/run_request.pi_core.json
  • sdks/python/oss/tests/pytest/unit/agents/test_tracing.py
  • sdks/python/oss/tests/pytest/unit/agents/test_wire_contract.py
  • services/runner/src/engines/sandbox_agent.ts
  • services/runner/src/engines/sandbox_agent/mount.ts
  • services/runner/src/engines/sandbox_agent/session-pool.ts
  • services/runner/src/protocol.ts
  • services/runner/src/server.ts
  • services/runner/tests/unit/session-keepalive-dispatch.test.ts
  • services/runner/tests/unit/session-pool.test.ts
  • services/runner/tests/unit/wire-contract.test.ts

Comment on lines +231 to +239
/**
* `presignedMount` follows the same convention as `runCold`: a value threads the up-front
* sign in, null = signed with no mount (do not re-sign, run mount-less), undefined = the
* up-front sign attempt threw (the acquire retries the sign itself).
*/
acquireEnvironment(
request: AgentRunRequest,
signal: AbortSignal | undefined,
presignedMount: MountCredentials | null,
presignedMount: MountCredentials | null | undefined,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Stale doc comment on resolveKeepaliveMount at line 227.

The adjacent interface comment says Null = no mount = never park, but the new behavior (and the updated resolveKeepaliveMount JSDoc in sandbox_agent.ts lines 480–482) is that null no longer forces a cold run by itself — parking proceeds when runContext.project.id supplies a scope. This comment now contradicts the implementation and the sibling documentation.

📝 Proposed fix for stale comment
-  /** Sign the session's durable mount once, up front. Null = no mount = never park. */
+  /** Sign the session's durable mount once, up front. Null = no mount; parking still
+   *  proceeds when `runContext.project.id` supplies a scope (see `poolKeyFor`). */

…er it over the mount

Decision 1 option (b): the runner's keep-alive pool scopes sessions by project id.
Until now that scope came only from the mount-sign response, so a run without a
durable mount could never park. The trustworthy project id now rides runContext:
the service stamps it server-side from the request's authenticated baggage (never
from a caller-supplied wire field), and the runner PREFERS runContext.project.id,
falling back to the mount scope. The no-scope-no-park safety rule is unchanged.

Claude-Session: https://claude.ai/code/session_01AumZJ9xRd4XYNHThqy4rTv
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant