Skip to content

Commit d68edd6

Browse files
fix: address Alain's PR review findings
1. Single source of truth for runtimeArn — removed constructor param, strategy now reads exclusively from blueprintConfig.runtime_arn 2. Lazy singleton for BedrockAgentCoreClient — module-level shared client avoids creating new TLS sessions per invocation 3. ComputeType union type ('agentcore' | 'ecs') with exhaustive switch and never-pattern in resolveComputeStrategy 4. Differentiated error handling in stopSession — ResourceNotFoundException (info), ThrottlingException/AccessDeniedException (error), others (warn) 5. Added logger.info('Session started') after full invoke+transition+event sequence in orchestrate-task.ts 6. Added start-session-composition.test.ts with integration tests for happy path, error path (failTask), and partial failure (transitionTask throws) 7. pollSession now throws NotImplementedError instead of returning stale 'running' status — clear signal for future developers
1 parent eba70ca commit d68edd6

8 files changed

Lines changed: 307 additions & 49 deletions

File tree

cdk/src/handlers/orchestrate-task.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import { withDurableExecution, type DurableExecutionHandler } from '@aws/durable-execution-sdk-js';
2121
import { TaskStatus, TERMINAL_STATUSES } from '../constructs/task-status';
2222
import { resolveComputeStrategy } from './shared/compute-strategy';
23+
import { logger } from './shared/logger';
2324
import {
2425
admissionControl,
2526
emitTaskEvent,
@@ -132,6 +133,12 @@ const durableHandler: DurableExecutionHandler<OrchestrateTaskEvent, void> = asyn
132133
strategy_type: handle.strategyType,
133134
});
134135

136+
logger.info('Session started', {
137+
task_id: taskId,
138+
session_id: handle.sessionId,
139+
strategy_type: handle.strategyType,
140+
});
141+
135142
return handle.sessionId;
136143
} catch (err) {
137144
await failTask(taskId, TaskStatus.HYDRATING, `Session start failed: ${String(err)}`, task.user_id, true);

cdk/src/handlers/shared/compute-strategy.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
* SOFTWARE.
1818
*/
1919

20-
import type { BlueprintConfig } from './repo-config';
20+
import type { BlueprintConfig, ComputeType } from './repo-config';
2121
import { AgentCoreComputeStrategy } from './strategies/agentcore-strategy';
2222

2323
export interface SessionHandle {
@@ -43,11 +43,15 @@ export interface ComputeStrategy {
4343
}
4444

4545
export function resolveComputeStrategy(blueprintConfig: BlueprintConfig): ComputeStrategy {
46-
const computeType = blueprintConfig.compute_type;
46+
const computeType: ComputeType = blueprintConfig.compute_type;
4747
switch (computeType) {
4848
case 'agentcore':
49-
return new AgentCoreComputeStrategy({ runtimeArn: blueprintConfig.runtime_arn });
50-
default:
51-
throw new Error(`Unknown compute_type: '${computeType}'`);
49+
return new AgentCoreComputeStrategy();
50+
case 'ecs':
51+
throw new Error("compute_type 'ecs' is not yet implemented");
52+
default: {
53+
const _exhaustive: never = computeType;
54+
throw new Error(`Unknown compute_type: '${_exhaustive}'`);
55+
}
5256
}
5357
}

cdk/src/handlers/shared/repo-config.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,14 @@ import { logger } from './logger';
2525
* Per-repository configuration written by the Blueprint CDK construct
2626
* and read at runtime by the task API gate and the orchestrator.
2727
*/
28+
export type ComputeType = 'agentcore' | 'ecs';
29+
2830
export interface RepoConfig {
2931
readonly repo: string;
3032
readonly status: 'active' | 'removed';
3133
readonly onboarded_at: string;
3234
readonly updated_at: string;
33-
readonly compute_type?: string;
35+
readonly compute_type?: ComputeType;
3436
readonly runtime_arn?: string;
3537
readonly model_id?: string;
3638
readonly max_turns?: number;
@@ -46,7 +48,7 @@ export interface RepoConfig {
4648
* settings with platform defaults.
4749
*/
4850
export interface BlueprintConfig {
49-
readonly compute_type: string;
51+
readonly compute_type: ComputeType;
5052
readonly runtime_arn: string;
5153
readonly model_id?: string;
5254
readonly max_turns?: number;

cdk/src/handlers/shared/strategies/agentcore-strategy.ts

Lines changed: 31 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -23,15 +23,16 @@ import type { ComputeStrategy, SessionHandle, SessionStatus } from '../compute-s
2323
import { logger } from '../logger';
2424
import type { BlueprintConfig } from '../repo-config';
2525

26+
let sharedClient: BedrockAgentCoreClient | undefined;
27+
function getClient(): BedrockAgentCoreClient {
28+
if (!sharedClient) {
29+
sharedClient = new BedrockAgentCoreClient({});
30+
}
31+
return sharedClient;
32+
}
33+
2634
export class AgentCoreComputeStrategy implements ComputeStrategy {
2735
readonly type = 'agentcore';
28-
private readonly client: BedrockAgentCoreClient;
29-
private readonly runtimeArn: string;
30-
31-
constructor(options: { runtimeArn: string }) {
32-
this.runtimeArn = options.runtimeArn;
33-
this.client = new BedrockAgentCoreClient({});
34-
}
3536

3637
async startSession(input: {
3738
taskId: string;
@@ -40,7 +41,7 @@ export class AgentCoreComputeStrategy implements ComputeStrategy {
4041
}): Promise<SessionHandle> {
4142
// AgentCore requires runtimeSessionId >= 33 chars; UUID v4 is 36 chars.
4243
const sessionId = randomUUID();
43-
const runtimeArn = input.blueprintConfig.runtime_arn ?? this.runtimeArn;
44+
const runtimeArn = input.blueprintConfig.runtime_arn;
4445

4546
const command = new InvokeAgentRuntimeCommand({
4647
agentRuntimeArn: runtimeArn,
@@ -50,7 +51,7 @@ export class AgentCoreComputeStrategy implements ComputeStrategy {
5051
payload: new TextEncoder().encode(JSON.stringify({ input: input.payload })),
5152
});
5253

53-
await this.client.send(command);
54+
await getClient().send(command);
5455

5556
logger.info('AgentCore session invoked', { task_id: input.taskId, session_id: sessionId, runtime_arn: runtimeArn });
5657

@@ -61,10 +62,12 @@ export class AgentCoreComputeStrategy implements ComputeStrategy {
6162
};
6263
}
6364

65+
/**
66+
* Not implemented for AgentCore — polling is done at the orchestrator level via DDB reads.
67+
* Future strategies (e.g. ECS/Fargate) will implement compute-level polling here.
68+
*/
6469
async pollSession(_handle: SessionHandle): Promise<SessionStatus> {
65-
// Polling is currently done at the orchestrator level via DDB reads.
66-
// This method exists for PR 2 where different strategies may poll differently.
67-
return { status: 'running' };
70+
throw new Error('pollSession is not implemented for AgentCore — use orchestrator-level DDB polling');
6871
}
6972

7073
async stopSession(handle: SessionHandle): Promise<void> {
@@ -75,16 +78,27 @@ export class AgentCoreComputeStrategy implements ComputeStrategy {
7578
}
7679

7780
try {
78-
await this.client.send(new StopRuntimeSessionCommand({
81+
await getClient().send(new StopRuntimeSessionCommand({
7982
agentRuntimeArn: runtimeArn,
8083
runtimeSessionId: handle.sessionId,
8184
}));
8285
logger.info('AgentCore session stopped', { session_id: handle.sessionId });
8386
} catch (err) {
84-
logger.warn('Failed to stop AgentCore session (best-effort)', {
85-
session_id: handle.sessionId,
86-
error: err instanceof Error ? err.message : String(err),
87-
});
87+
const errName = err instanceof Error ? (err as Error & { name?: string }).name : undefined;
88+
if (errName === 'ResourceNotFoundException') {
89+
logger.info('AgentCore session already gone', { session_id: handle.sessionId });
90+
} else if (errName === 'ThrottlingException' || errName === 'AccessDeniedException') {
91+
logger.error('Failed to stop AgentCore session', {
92+
session_id: handle.sessionId,
93+
error_type: errName,
94+
error: err instanceof Error ? err.message : String(err),
95+
});
96+
} else {
97+
logger.warn('Failed to stop AgentCore session (best-effort)', {
98+
session_id: handle.sessionId,
99+
error: err instanceof Error ? err.message : String(err),
100+
});
101+
}
88102
}
89103
}
90104
}

cdk/test/handlers/shared/compute-strategy.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,12 +36,12 @@ describe('resolveComputeStrategy', () => {
3636
expect(strategy.type).toBe('agentcore');
3737
});
3838

39-
test('throws for unknown compute_type', () => {
39+
test("throws 'not yet implemented' for compute_type ecs", () => {
4040
expect(() =>
4141
resolveComputeStrategy({
42-
compute_type: 'unknown',
42+
compute_type: 'ecs',
4343
runtime_arn: 'arn:test',
4444
}),
45-
).toThrow("Unknown compute_type: 'unknown'");
45+
).toThrow("compute_type 'ecs' is not yet implemented");
4646
});
4747
});

cdk/test/handlers/shared/preflight.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ const mockFetch = jest.fn();
4040
global.fetch = mockFetch as unknown as typeof fetch;
4141

4242
const baseBlueprintConfig: BlueprintConfig = {
43-
compute_type: 'AGENTCORE',
43+
compute_type: 'agentcore',
4444
runtime_arn: 'arn:aws:bedrock:us-east-1:123456789012:agent-runtime/test',
4545
github_token_secret_arn: 'arn:aws:secretsmanager:us-east-1:123456789012:secret:github-token',
4646
};

cdk/test/handlers/shared/strategies/agentcore-strategy.test.ts

Lines changed: 94 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -34,14 +34,14 @@ beforeEach(() => {
3434

3535
describe('AgentCoreComputeStrategy', () => {
3636
test('type is agentcore', () => {
37-
const strategy = new AgentCoreComputeStrategy({ runtimeArn: defaultRuntimeArn });
37+
const strategy = new AgentCoreComputeStrategy();
3838
expect(strategy.type).toBe('agentcore');
3939
});
4040

4141
describe('startSession', () => {
4242
test('invokes agent runtime and returns SessionHandle', async () => {
4343
mockSend.mockResolvedValueOnce({});
44-
const strategy = new AgentCoreComputeStrategy({ runtimeArn: defaultRuntimeArn });
44+
const strategy = new AgentCoreComputeStrategy();
4545

4646
const handle = await strategy.startSession({
4747
taskId: 'TASK001',
@@ -55,39 +55,67 @@ describe('AgentCoreComputeStrategy', () => {
5555
expect(mockSend).toHaveBeenCalledTimes(1);
5656
});
5757

58-
test('uses blueprint runtime_arn override', async () => {
58+
test('uses runtime_arn from blueprintConfig (single source of truth)', async () => {
5959
mockSend.mockResolvedValueOnce({});
60-
const strategy = new AgentCoreComputeStrategy({ runtimeArn: defaultRuntimeArn });
61-
const overrideArn = 'arn:aws:bedrock-agentcore:us-east-1:123:runtime/custom';
60+
const strategy = new AgentCoreComputeStrategy();
61+
const runtimeArn = 'arn:aws:bedrock-agentcore:us-east-1:123:runtime/custom';
6262

6363
const handle = await strategy.startSession({
6464
taskId: 'TASK001',
6565
payload: { repo_url: 'org/repo', task_id: 'TASK001' },
66-
blueprintConfig: { compute_type: 'agentcore', runtime_arn: overrideArn },
66+
blueprintConfig: { compute_type: 'agentcore', runtime_arn: runtimeArn },
6767
});
6868

69-
expect(handle.metadata.runtimeArn).toBe(overrideArn);
69+
expect(handle.metadata.runtimeArn).toBe(runtimeArn);
7070
const invokeCall = mockSend.mock.calls[0][0];
71-
expect(invokeCall.input.agentRuntimeArn).toBe(overrideArn);
71+
expect(invokeCall.input.agentRuntimeArn).toBe(runtimeArn);
72+
});
73+
74+
test('reuses shared BedrockAgentCoreClient across instances', async () => {
75+
const { BedrockAgentCoreClient } = require('@aws-sdk/client-bedrock-agentcore');
76+
// The lazy singleton may already be initialized from prior tests.
77+
// Record the current call count, then verify no additional constructor calls happen.
78+
const callsBefore = BedrockAgentCoreClient.mock.calls.length;
79+
80+
mockSend.mockResolvedValue({});
81+
const strategy1 = new AgentCoreComputeStrategy();
82+
const strategy2 = new AgentCoreComputeStrategy();
83+
84+
await strategy1.startSession({
85+
taskId: 'T1',
86+
payload: {},
87+
blueprintConfig: { compute_type: 'agentcore', runtime_arn: defaultRuntimeArn },
88+
});
89+
await strategy2.startSession({
90+
taskId: 'T2',
91+
payload: {},
92+
blueprintConfig: { compute_type: 'agentcore', runtime_arn: defaultRuntimeArn },
93+
});
94+
95+
// Lazy singleton: at most one constructor call total across all strategy instances
96+
const callsAfter = BedrockAgentCoreClient.mock.calls.length;
97+
expect(callsAfter - callsBefore).toBeLessThanOrEqual(1);
98+
expect(mockSend).toHaveBeenCalledTimes(2);
7299
});
73100
});
74101

75102
describe('pollSession', () => {
76-
test('returns running status', async () => {
77-
const strategy = new AgentCoreComputeStrategy({ runtimeArn: defaultRuntimeArn });
78-
const result = await strategy.pollSession({
79-
sessionId: 'test-session',
80-
strategyType: 'agentcore',
81-
metadata: { runtimeArn: defaultRuntimeArn },
82-
});
83-
expect(result.status).toBe('running');
103+
test('throws because AgentCore polling is done at orchestrator level', async () => {
104+
const strategy = new AgentCoreComputeStrategy();
105+
await expect(
106+
strategy.pollSession({
107+
sessionId: 'test-session',
108+
strategyType: 'agentcore',
109+
metadata: { runtimeArn: defaultRuntimeArn },
110+
}),
111+
).rejects.toThrow('pollSession is not implemented for AgentCore');
84112
});
85113
});
86114

87115
describe('stopSession', () => {
88116
test('sends StopRuntimeSessionCommand', async () => {
89117
mockSend.mockResolvedValueOnce({});
90-
const strategy = new AgentCoreComputeStrategy({ runtimeArn: defaultRuntimeArn });
118+
const strategy = new AgentCoreComputeStrategy();
91119

92120
await strategy.stopSession({
93121
sessionId: 'test-session',
@@ -101,9 +129,54 @@ describe('AgentCoreComputeStrategy', () => {
101129
expect(call.input.runtimeSessionId).toBe('test-session');
102130
});
103131

104-
test('does not throw when stop fails', async () => {
105-
mockSend.mockRejectedValueOnce(new Error('Access denied'));
106-
const strategy = new AgentCoreComputeStrategy({ runtimeArn: defaultRuntimeArn });
132+
test('logs info for ResourceNotFoundException (session already gone)', async () => {
133+
const err = new Error('Not found');
134+
err.name = 'ResourceNotFoundException';
135+
mockSend.mockRejectedValueOnce(err);
136+
const strategy = new AgentCoreComputeStrategy();
137+
138+
await expect(
139+
strategy.stopSession({
140+
sessionId: 'test-session',
141+
strategyType: 'agentcore',
142+
metadata: { runtimeArn: defaultRuntimeArn },
143+
}),
144+
).resolves.toBeUndefined();
145+
});
146+
147+
test('logs error for ThrottlingException', async () => {
148+
const err = new Error('Rate exceeded');
149+
err.name = 'ThrottlingException';
150+
mockSend.mockRejectedValueOnce(err);
151+
const strategy = new AgentCoreComputeStrategy();
152+
153+
await expect(
154+
strategy.stopSession({
155+
sessionId: 'test-session',
156+
strategyType: 'agentcore',
157+
metadata: { runtimeArn: defaultRuntimeArn },
158+
}),
159+
).resolves.toBeUndefined();
160+
});
161+
162+
test('logs error for AccessDeniedException', async () => {
163+
const err = new Error('Access denied');
164+
err.name = 'AccessDeniedException';
165+
mockSend.mockRejectedValueOnce(err);
166+
const strategy = new AgentCoreComputeStrategy();
167+
168+
await expect(
169+
strategy.stopSession({
170+
sessionId: 'test-session',
171+
strategyType: 'agentcore',
172+
metadata: { runtimeArn: defaultRuntimeArn },
173+
}),
174+
).resolves.toBeUndefined();
175+
});
176+
177+
test('logs warn for unknown errors (best-effort)', async () => {
178+
mockSend.mockRejectedValueOnce(new Error('Network error'));
179+
const strategy = new AgentCoreComputeStrategy();
107180

108181
await expect(
109182
strategy.stopSession({
@@ -115,7 +188,7 @@ describe('AgentCoreComputeStrategy', () => {
115188
});
116189

117190
test('skips stop when no runtimeArn in metadata', async () => {
118-
const strategy = new AgentCoreComputeStrategy({ runtimeArn: defaultRuntimeArn });
191+
const strategy = new AgentCoreComputeStrategy();
119192

120193
await strategy.stopSession({
121194
sessionId: 'test-session',

0 commit comments

Comments
 (0)