Skip to content

Commit 517fec1

Browse files
committed
feat(runtime): add persistent session storage
1 parent 1f32038 commit 517fec1

11 files changed

Lines changed: 150 additions & 15 deletions

File tree

agent/entrypoint.py

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@
3737
# Configuration
3838
# ---------------------------------------------------------------------------
3939

40+
AGENT_WORKSPACE = os.environ.get("AGENT_WORKSPACE", "/workspace")
41+
4042

4143
def resolve_github_token() -> str:
4244
"""Resolve GitHub token from Secrets Manager or environment variable.
@@ -298,7 +300,7 @@ def setup_repo(config: dict) -> dict:
298300
Returns a dict with keys: repo_dir, branch, notes, build_before,
299301
lint_before, and default_branch.
300302
"""
301-
repo_dir = f"/workspace/{config['task_id']}"
303+
repo_dir = f"{AGENT_WORKSPACE}/{config['task_id']}"
302304
setup: dict[str, str | list[str] | bool] = {"repo_dir": repo_dir, "notes": []}
303305

304306
# Derive branch slug from issue title or task description
@@ -311,6 +313,14 @@ def setup_repo(config: dict) -> dict:
311313
branch = f"bgagent/{config['task_id']}/{slug}"
312314
setup["branch"] = branch
313315

316+
# Mark the repo directory as safe for git. On persistent session storage
317+
# the mount may be owned by a different UID than the container user,
318+
# triggering git's "dubious ownership" check on clone/resume.
319+
run_cmd(
320+
["git", "config", "--global", "--add", "safe.directory", repo_dir],
321+
label="safe-directory",
322+
)
323+
314324
# Clone
315325
log("SETUP", f"Cloning {config['repo_url']}...")
316326
run_cmd(
@@ -794,7 +804,7 @@ def _extract_agent_notes(repo_dir: str, branch: str, config: dict) -> str | None
794804
# ---------------------------------------------------------------------------
795805

796806

797-
def get_disk_usage(path: str = "/workspace") -> float:
807+
def get_disk_usage(path: str = AGENT_WORKSPACE) -> float:
798808
"""Return disk usage in bytes for the given path."""
799809
try:
800810
result = subprocess.run(
@@ -1225,7 +1235,9 @@ def _setup_agent_env(config: dict) -> tuple[str | None, str | None]:
12251235
return otlp_endpoint, otlp_protocol
12261236

12271237

1228-
async def run_agent(prompt: str, system_prompt: str, config: dict, cwd: str = "/workspace") -> dict:
1238+
async def run_agent(
1239+
prompt: str, system_prompt: str, config: dict, cwd: str = AGENT_WORKSPACE
1240+
) -> dict:
12291241
"""Invoke the Claude Agent SDK and stream output."""
12301242
from claude_agent_sdk import (
12311243
AssistantMessage,
@@ -1472,6 +1484,7 @@ def _build_system_prompt(
14721484
"""Assemble the system prompt with task-specific values and memory context."""
14731485
system_prompt = SYSTEM_PROMPT.replace("{repo_url}", config["repo_url"])
14741486
system_prompt = system_prompt.replace("{task_id}", config["task_id"])
1487+
system_prompt = system_prompt.replace("{workspace}", AGENT_WORKSPACE)
14751488
system_prompt = system_prompt.replace("{branch_name}", setup["branch"])
14761489
default_branch = setup.get("default_branch", "main")
14771490
system_prompt = system_prompt.replace("{default_branch}", default_branch)
@@ -1719,7 +1732,7 @@ def run_task(
17191732
log("TASK", "No repo-level project configuration found")
17201733

17211734
# Run agent
1722-
disk_before = get_disk_usage("/workspace")
1735+
disk_before = get_disk_usage(AGENT_WORKSPACE)
17231736
start_time = time.time()
17241737

17251738
log("TASK", "Starting agent...")
@@ -1781,7 +1794,7 @@ def run_task(
17811794

17821795
# Metrics
17831796
duration = time.time() - start_time
1784-
disk_after = get_disk_usage("/workspace")
1797+
disk_after = get_disk_usage(AGENT_WORKSPACE)
17851798

17861799
# Determine overall status:
17871800
# - "success" if the agent reported success/end_turn and the build passes
@@ -1916,6 +1929,7 @@ def main():
19161929
prompt = assemble_prompt(config)
19171930
system_prompt = SYSTEM_PROMPT.replace("{repo_url}", config["repo_url"])
19181931
system_prompt = system_prompt.replace("{task_id}", config["task_id"])
1932+
system_prompt = system_prompt.replace("{workspace}", AGENT_WORKSPACE)
19191933
system_prompt = system_prompt.replace("{branch_name}", "bgagent/{task_id}/dry-run")
19201934
system_prompt = system_prompt.replace("{default_branch}", "main")
19211935
system_prompt = system_prompt.replace("{max_turns}", str(config.get("max_turns", 100)))

agent/system_prompt.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
Placeholders replaced at runtime by entrypoint.py:
77
{repo_url} — GitHub repo (owner/repo)
88
{task_id} — Unique task identifier
9+
{workspace} — Workspace root (AGENT_WORKSPACE env var, default /workspace)
910
{branch_name} — Git branch created by the entrypoint
1011
{default_branch} — Repository default branch (e.g. main, master)
1112
{max_turns} — Maximum agent turns for this task
@@ -20,7 +21,7 @@
2021
## Environment
2122
2223
- You are running inside an isolated container with shell access.
23-
- The repository `{repo_url}` is already cloned at `/workspace/{task_id}`.
24+
- The repository `{repo_url}` is already cloned at `{workspace}/{task_id}`.
2425
- You are on branch `{branch_name}`.
2526
- The repository default branch is `{default_branch}`.
2627
- Git is configured and authenticated — `git push` works without extra setup.

agent/tests/test_entrypoint.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,32 @@
1616
truncate,
1717
)
1818

19+
# ---------------------------------------------------------------------------
20+
# AGENT_WORKSPACE
21+
# ---------------------------------------------------------------------------
22+
23+
24+
class TestAgentWorkspace:
25+
def test_defaults_to_workspace(self, monkeypatch):
26+
monkeypatch.delenv("AGENT_WORKSPACE", raising=False)
27+
# Re-import to pick up the env change
28+
import importlib
29+
30+
import entrypoint
31+
32+
importlib.reload(entrypoint)
33+
assert entrypoint.AGENT_WORKSPACE == "/workspace"
34+
35+
def test_reads_env_var(self, monkeypatch):
36+
monkeypatch.setenv("AGENT_WORKSPACE", "/mnt/workspace")
37+
import importlib
38+
39+
import entrypoint
40+
41+
importlib.reload(entrypoint)
42+
assert entrypoint.AGENT_WORKSPACE == "/mnt/workspace"
43+
44+
1945
# ---------------------------------------------------------------------------
2046
# slugify
2147
# ---------------------------------------------------------------------------

cdk/src/stacks/agent.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import * as path from 'path';
2121
import * as agentcore from '@aws-cdk/aws-bedrock-agentcore-alpha';
2222
import * as bedrock from '@aws-cdk/aws-bedrock-alpha';
2323
import * as agentcoremixins from '@aws-cdk/mixins-preview/aws-bedrockagentcore';
24-
import { Stack, StackProps, RemovalPolicy, CfnOutput } from 'aws-cdk-lib';
24+
import { Stack, StackProps, RemovalPolicy, CfnOutput, CfnResource } from 'aws-cdk-lib';
2525
import * as ec2 from 'aws-cdk-lib/aws-ec2';
2626
import * as iam from 'aws-cdk-lib/aws-iam';
2727
import * as logs from 'aws-cdk-lib/aws-logs';
@@ -143,10 +143,35 @@ export class AgentStack extends Stack {
143143
LOG_GROUP_NAME: applicationLogGroup.logGroupName,
144144
MEMORY_ID: agentMemory.memory.memoryId,
145145
MAX_TURNS: '100',
146+
// Session storage: the S3-backed FUSE mount at /mnt/workspace does NOT
147+
// support flock(). Only caches whose tools never call flock() go there.
148+
// Everything else stays on local ephemeral disk.
149+
//
150+
// Local disk (tools use flock):
151+
// AGENT_WORKSPACE — omitted, defaults to /workspace
152+
// MISE_DATA_DIR — mise's pipx backend sets UV_TOOL_DIR inside installs/,
153+
// and uv flocks that directory → must be local.
154+
MISE_DATA_DIR: '/tmp/mise-data',
155+
UV_CACHE_DIR: '/tmp/uv-cache',
156+
// Persistent mount (no flock):
157+
CLAUDE_CONFIG_DIR: '/mnt/workspace/.claude-config',
158+
npm_config_cache: '/mnt/workspace/.npm-cache',
146159
// ENABLE_CLI_TELEMETRY: '1',
147160
},
148161
});
149162

163+
// --- Session storage (preview) ---
164+
// The L2 construct does not yet expose filesystemConfigurations.
165+
// Use a CFN escape hatch until the L2 adds native support.
166+
const cfnRuntime = runtime.node.defaultChild as CfnResource;
167+
cfnRuntime.addPropertyOverride('FilesystemConfigurations', [
168+
{
169+
SessionStorage: {
170+
MountPath: '/mnt/workspace',
171+
},
172+
},
173+
]);
174+
150175
taskTable.table.grantReadWriteData(runtime);
151176
taskEventsTable.table.grantReadWriteData(runtime);
152177
userConcurrencyTable.table.grantReadWriteData(runtime);

cdk/test/stacks/agent.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,31 @@ describe('AgentStack', () => {
172172
expect(loggingConfigs.length).toBe(1);
173173
});
174174

175+
test('enables session storage with persistent filesystem', () => {
176+
template.hasResourceProperties('AWS::BedrockAgentCore::Runtime', {
177+
FilesystemConfigurations: [
178+
{
179+
SessionStorage: {
180+
MountPath: '/mnt/workspace',
181+
},
182+
},
183+
],
184+
});
185+
});
186+
187+
test('sets cache env vars on runtime (persistent mount + local for flock)', () => {
188+
template.hasResourceProperties('AWS::BedrockAgentCore::Runtime', {
189+
EnvironmentVariables: Match.objectLike({
190+
// Local disk — tools use flock()
191+
MISE_DATA_DIR: '/tmp/mise-data',
192+
UV_CACHE_DIR: '/tmp/uv-cache',
193+
// Persistent mount — no flock()
194+
CLAUDE_CONFIG_DIR: '/mnt/workspace/.claude-config',
195+
npm_config_cache: '/mnt/workspace/.npm-cache',
196+
}),
197+
});
198+
});
199+
175200
test('creates AgentCore Memory resource', () => {
176201
template.resourceCountIs('AWS::BedrockAgentCore::Memory', 1);
177202
});

docs/design/AGENT_HARNESS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ The following are desired properties for the harness; MVP satisfies some and def
3939
- **Deterministic hooks** — Support for deterministic steps or hooks (e.g. pre/post tool execution, validation) so the platform can mix coded logic with the agent loop. The **blueprint execution framework** (see [REPO_ONBOARDING.md](./REPO_ONBOARDING.md#blueprint-execution-framework)) realizes this requirement: the orchestrator runs custom Lambda-backed steps at configurable pipeline phases (`pre-agent`, `post-agent`) with framework-enforced invariants (state transitions, events, cancellation). The agent harness itself does not need to implement hooks — they run at the orchestrator level, outside the agent session.
4040
- **Plugins / skills / MCP** — Support for plugins, skills, or MCP servers for extensibility. Out of scope for MVP.
4141
- **Access to external memory** — The agent should be able to read and write short- and long-term memory (e.g. AgentCore Memory). MVP: AgentCore Memory is available to the agent via the runtime; the SDK or platform wires it in.
42-
- **Session persistence** — Persisting conversation and agent state across session boundaries for crash recovery or resume. MVP: Claude Code SDK has no built-in session manager; durability is via frequent commits.
42+
- **Session persistence** — Persisting conversation and agent state across session boundaries for crash recovery or resume. MVP: Claude Code SDK has no built-in session manager; durability is via frequent commits. **Update:** AgentCore Runtime persistent session storage (preview) now mounts a per-session filesystem at `/mnt/workspace` that survives stop/resume cycles. Tool caches (mise, npm, Claude Code config) persist across invocations within a session (14-day TTL). Repo clones remain on local ephemeral disk because the S3-backed FUSE mount does not support `flock()`, which breaks build tools like `uv`. See [COMPUTE.md](./COMPUTE.md#session-storage-persistent-filesystem).
4343

4444
## Diagnostic tools
4545

0 commit comments

Comments
 (0)