Skip to content

Commit e0efe30

Browse files
committed
fix+test: guard session-start retry emit, extract + test it, classify raw error
Addresses the #599 review (Stack B): - B1 (MEDIUM): the `session_start_retry` telemetry emit was unguarded — a TaskEvents PutItem fault (throttle/timeout, co-occurring with the transient session-start failures this path handles) threw AFTER `autoRetried=true` but BEFORE the retry ran, so the user was told a second attempt failed when none had, and the real retriable cause was discarded. The emit is now best-effort (try/catch → WARN-and-continue) so telemetry never aborts/mis-attributes the retry. The `correlation` envelope is threaded so the retry event carries the same trace context as `session_started` (#245). - B2 (crit-9): the auto-retry had ZERO coverage (it lived inline in the durable handler's start-session step the suite never invokes). Extracted to an exported `startSessionWithRetry(strategy, input, deps)` — matching the repo's "test the exported helper" pattern — and unit-tested all four branches (success / non-transient no-retry / transient→retry→success / transient→transient→throw) plus the B1 best-effort-emit guard. - N2: classify the RAW error, not a ``Session start failed: …`` wrapper. The wrapper string itself matched a TRANSIENT pattern, so EVERY session-start failure classified transient and the "non-transient throws immediately" branch was effectively dead (a config/auth fault ate a pointless ~1-min retry). Classifying the raw string restores that branch (verified: TaskDefinition- inactive → retry; ECS_CLUSTER_ARN-not-configured / AccessDenied → surface). - N1: soften the `[auto-retried]` comment — it forward-referenced renderFailureReply/AUTO_RETRIED_MARKER that don't exist yet. Now states the marker is persisted verbatim for a forthcoming failure renderer (retryGuidance ships ahead of its consumer); no behaviour claim about rendering today. Full cdk build green (compile + jest + eslint + synth); 5 new helper tests pass.
1 parent bb1768c commit e0efe30

3 files changed

Lines changed: 260 additions & 32 deletions

File tree

cdk/src/handlers/orchestrate-task.ts

Lines changed: 25 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@
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 { classifyError, isTransientError } from './shared/error-classifier';
2423
import { reportIssueFailure as reportJiraIssueFailure } from './shared/jira-feedback';
2524
import { reportIssueFailure } from './shared/linear-feedback';
2625
import { logger } from './shared/logger';
@@ -38,6 +37,7 @@ import {
3837
type PollState,
3938
} from './shared/orchestrator';
4039
import { runPreflightChecks } from './shared/preflight';
40+
import { startSessionWithRetry } from './shared/session-start-retry';
4141
import type { TaskRecord } from './shared/types';
4242
import { workflowIsReadOnly, workflowRequiresRepo } from './shared/workflows';
4343

@@ -169,33 +169,23 @@ const durableHandler: DurableExecutionHandler<OrchestrateTaskEvent, void> = asyn
169169
payload,
170170
blueprintConfig,
171171
};
172-
// Transient-error AUTO-RETRY (once). session-start is the ONE place a retry
173-
// is idempotent by construction — no repo clone, no commits, no PR have
174-
// happened yet, so re-invoking RunTask/InvokeAgentRuntime can't double-run
175-
// work. A transient hiccup here (ECS deploy-race "TaskDefinition is inactive",
176-
// ENI/capacity delay, a Bedrock/agentcore throttle) usually clears on a second
177-
// attempt — so we swallow the first transient failure and try once more before
178-
// surfacing anything to the user. A NON-transient failure (bad config, missing
179-
// ECS substrate) throws immediately — retrying it just wastes ~a minute. Mid-run
180-
// crashes are NOT retried here (step 5); the agent may have pushed commits.
181-
let handle;
182-
try {
183-
handle = await strategy.startSession(startInput);
184-
} catch (firstErr) {
185-
const classification = classifyError(`Session start failed: ${String(firstErr)}`);
186-
if (!isTransientError(classification)) {
187-
throw firstErr; // service/user error — a retry won't help; surface now.
188-
}
189-
autoRetried = true;
190-
logger.warn('Session start hit a transient error — auto-retrying once', {
191-
task_id: taskId,
192-
error: firstErr instanceof Error ? firstErr.message : String(firstErr),
193-
});
194-
await emitTaskEvent(taskId, 'session_start_retry', {
195-
reason: classification?.title ?? 'transient',
196-
});
197-
handle = await strategy.startSession(startInput);
198-
}
172+
// Transient-error AUTO-RETRY (once), extracted to startSessionWithRetry so
173+
// the four branches are unit-tested (#599 B2). The retry-event emit is
174+
// best-effort and guarded there (#599 B1): a TaskEvents PutItem fault can't
175+
// abort or mis-attribute the retry. The correlation envelope is threaded so
176+
// the session_start_retry event carries the same trace context as
177+
// session_started below (#245).
178+
const { handle, autoRetried: retried } = await startSessionWithRetry(
179+
strategy,
180+
startInput,
181+
{
182+
emitRetryEvent: (reason) =>
183+
emitTaskEvent(taskId, 'session_start_retry', { reason }, correlation),
184+
logger,
185+
taskId,
186+
},
187+
);
188+
autoRetried = retried;
199189

200190
// Build compute metadata for the task record so cancel-task can stop the right backend
201191
const computeMetadata: Record<string, string> = handle.strategyType === 'ecs'
@@ -221,10 +211,13 @@ const durableHandler: DurableExecutionHandler<OrchestrateTaskEvent, void> = asyn
221211

222212
return handle;
223213
} catch (err) {
224-
// Carry the auto-retry fact into error_message so the channel surface can say
225-
// "I already tried again" (a bare marker the classifier ignores but
226-
// renderFailureReply detects — see AUTO_RETRIED_MARKER). Only stamped when the
227-
// single transient retry above also failed.
214+
// Carry the auto-retry fact into error_message: the `[auto-retried]` suffix
215+
// is persisted verbatim (the classifier ignores it — it does not affect
216+
// classification). It is a breadcrumb for a FORTHCOMING failure renderer to
217+
// detect and surface "I already tried again" to the channel — no consumer
218+
// renders it yet on this branch (retryGuidance() in error-classifier.ts is
219+
// the intended copy source; it ships ahead of its consumer). Only stamped
220+
// when the single transient retry above also failed.
228221
const retriedNote = autoRetried ? ' [auto-retried]' : '';
229222
await failTask(taskId, TaskStatus.HYDRATING, `Session start failed: ${String(err)}${retriedNote}`, task.user_id, true, task.repo);
230223
throw err;
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
/**
2+
* MIT No Attribution
3+
*
4+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
5+
*
6+
* Permission is hereby granted, free of charge, to any person obtaining a copy of
7+
* the Software without restriction, including without limitation the rights to
8+
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
9+
* the Software, and to permit persons to whom the Software is furnished to do so.
10+
*
11+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
12+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
13+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
14+
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
15+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
16+
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
17+
* SOFTWARE.
18+
*/
19+
20+
/**
21+
* Session-start transient auto-retry (once), extracted from the durable
22+
* ``orchestrate-task`` handler so the four retry branches are unit-testable in
23+
* isolation (the handler's inline ``start-session`` step is never invoked by
24+
* the test suite). See #599 review B1/B2.
25+
*
26+
* session-start is the ONE place a retry is idempotent by construction — no repo
27+
* clone, no commits, no PR have happened yet, so re-invoking
28+
* RunTask/InvokeAgentRuntime can't double-run work. A transient hiccup here (an
29+
* ECS deploy-race "TaskDefinition is inactive", ENI/capacity delay, a
30+
* Bedrock/agentcore throttle) usually clears on a second attempt, so the first
31+
* transient failure is swallowed and retried once. A NON-transient failure (bad
32+
* config, missing ECS substrate) is re-thrown immediately — retrying it just
33+
* wastes ~a minute. Mid-run crashes are NOT handled here (that's a later step;
34+
* the agent may have pushed commits).
35+
*/
36+
37+
import type { ComputeStrategy, SessionHandle } from './compute-strategy';
38+
import { classifyError, isTransientError } from './error-classifier';
39+
40+
/** Emit a ``session_start_retry`` telemetry event. Matches the shape of
41+
* ``emitTaskEvent`` bound at the call site (best-effort — see below). */
42+
export type RetryEventEmitter = (
43+
reason: string,
44+
) => Promise<void>;
45+
46+
/** Minimal logger surface (a subset of the handler's structured logger). */
47+
export interface RetryLogger {
48+
warn(message: string, meta?: Record<string, unknown>): void;
49+
}
50+
51+
export interface StartSessionWithRetryResult {
52+
readonly handle: SessionHandle;
53+
/** True iff the first attempt failed transiently and a second attempt ran. */
54+
readonly autoRetried: boolean;
55+
}
56+
57+
/**
58+
* Start a compute session, auto-retrying ONCE on a transient failure.
59+
*
60+
* Behaviour (the four branches #599 B2 asks be covered):
61+
* 1. first attempt succeeds → return it, ``autoRetried: false``.
62+
* 2. first attempt fails NON-transient → re-throw the original error (no retry).
63+
* 3. first attempt fails transient, retry succeeds → return it, ``autoRetried: true``.
64+
* 4. first attempt fails transient, retry also fails → throw the retry's error
65+
* (with ``autoRetried`` observable via the thrown-from state; the caller
66+
* stamps its own ``[auto-retried]`` marker — it owns ``autoRetried`` because
67+
* this function throws rather than returns on the double-failure).
68+
*
69+
* The ``emitRetryEvent`` call is BEST-EFFORT and internally guarded (B1): a
70+
* TaskEvents PutItem fault (throttle/timeout — exactly the conditions that
71+
* co-occur with the transient session-start failures this handles) must NOT
72+
* abort or mis-attribute the retry. A telemetry failure is logged and swallowed;
73+
* the retry proceeds regardless. Previously the emit was unguarded and, if it
74+
* threw after ``autoRetried`` was set but before the retry ran, the user was
75+
* told a second attempt failed when none had.
76+
*/
77+
export async function startSessionWithRetry(
78+
strategy: Pick<ComputeStrategy, 'startSession'>,
79+
input: Parameters<ComputeStrategy['startSession']>[0],
80+
deps: {
81+
emitRetryEvent: RetryEventEmitter;
82+
logger: RetryLogger;
83+
taskId: string;
84+
},
85+
): Promise<StartSessionWithRetryResult> {
86+
try {
87+
const handle = await strategy.startSession(input);
88+
return { handle, autoRetried: false };
89+
} catch (firstErr) {
90+
// Classify the RAW error, NOT a `Session start failed: …` wrapper (#599 N2):
91+
// `/Session start failed/i` is itself a TRANSIENT pattern, so wrapping made
92+
// EVERY session-start failure classify transient — a genuine config/auth
93+
// fault (missing ECS env, AccessDenied) would eat a pointless ~1-min retry
94+
// and the "non-transient throws immediately" branch below was effectively
95+
// dead. Classifying the raw string restores that branch. (Widening the
96+
// classifier's transient patterns — e.g. ThrottlingException — is a separate
97+
// classifier-completeness concern, not this retry gate.)
98+
const classification = classifyError(String(firstErr));
99+
if (!isTransientError(classification)) {
100+
throw firstErr; // service/user error — a retry won't help; surface now.
101+
}
102+
deps.logger.warn('Session start hit a transient error — auto-retrying once', {
103+
task_id: deps.taskId,
104+
error: firstErr instanceof Error ? firstErr.message : String(firstErr),
105+
});
106+
// Best-effort telemetry — a PutItem fault here must never abort the retry
107+
// or mis-report the outcome (B1).
108+
try {
109+
await deps.emitRetryEvent(classification?.title ?? 'transient');
110+
} catch (emitErr) {
111+
deps.logger.warn('session_start_retry event emit failed (non-fatal)', {
112+
task_id: deps.taskId,
113+
error: emitErr instanceof Error ? emitErr.message : String(emitErr),
114+
});
115+
}
116+
const handle = await strategy.startSession(input);
117+
return { handle, autoRetried: true };
118+
}
119+
}
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
/**
2+
* MIT No Attribution
3+
*
4+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
5+
*
6+
* Permission is hereby granted, free of charge, to any person obtaining a copy of
7+
* the Software without restriction, including without limitation the rights to
8+
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
9+
* the Software, and to permit persons to whom the Software is furnished to do so.
10+
*
11+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
12+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
13+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
14+
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
15+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
16+
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
17+
* SOFTWARE.
18+
*/
19+
20+
import type { SessionHandle } from '../../../src/handlers/shared/compute-strategy';
21+
import { startSessionWithRetry } from '../../../src/handlers/shared/session-start-retry';
22+
23+
const HANDLE: SessionHandle = {
24+
sessionId: 'sess-1',
25+
strategyType: 'agentcore',
26+
runtimeArn: 'arn:aws:bedrock-agentcore:us-east-1:1:runtime/r',
27+
};
28+
29+
/** A transient session-start failure the classifier recognizes (ECS deploy race). */
30+
const TRANSIENT = new Error('TaskDefinition is inactive');
31+
/** A non-transient failure (a retry won't help). */
32+
const NON_TRANSIENT = new Error('ECS_CLUSTER_ARN is not configured');
33+
34+
function deps(overrides?: { emitRetryEvent?: (reason: string) => Promise<void> }) {
35+
const warns: Array<{ message: string; meta?: Record<string, unknown> }> = [];
36+
const emitReasons: string[] = [];
37+
return {
38+
warns,
39+
emitReasons,
40+
d: {
41+
emitRetryEvent:
42+
overrides?.emitRetryEvent ??
43+
(async (reason: string) => {
44+
emitReasons.push(reason);
45+
}),
46+
logger: { warn: (message: string, meta?: Record<string, unknown>) => warns.push({ message, meta }) },
47+
taskId: 't-1',
48+
},
49+
};
50+
}
51+
52+
describe('startSessionWithRetry — the 4 branches (#599 B2)', () => {
53+
it('1. first attempt succeeds → returns handle, autoRetried false, no retry', async () => {
54+
const startSession = jest.fn().mockResolvedValueOnce(HANDLE);
55+
const { d, emitReasons } = deps();
56+
const res = await startSessionWithRetry({ startSession }, {} as never, d);
57+
expect(res).toEqual({ handle: HANDLE, autoRetried: false });
58+
expect(startSession).toHaveBeenCalledTimes(1);
59+
expect(emitReasons).toEqual([]); // no retry event on the happy path
60+
});
61+
62+
it('2. first attempt fails NON-transient → re-throws original, NO retry', async () => {
63+
const startSession = jest.fn().mockRejectedValueOnce(NON_TRANSIENT);
64+
const { d, emitReasons } = deps();
65+
await expect(startSessionWithRetry({ startSession }, {} as never, d)).rejects.toBe(NON_TRANSIENT);
66+
expect(startSession).toHaveBeenCalledTimes(1); // never retried
67+
expect(emitReasons).toEqual([]);
68+
});
69+
70+
it('3. transient then success → returns handle, autoRetried true, emits retry event', async () => {
71+
const startSession = jest
72+
.fn()
73+
.mockRejectedValueOnce(TRANSIENT)
74+
.mockResolvedValueOnce(HANDLE);
75+
const { d, emitReasons } = deps();
76+
const res = await startSessionWithRetry({ startSession }, {} as never, d);
77+
expect(res).toEqual({ handle: HANDLE, autoRetried: true });
78+
expect(startSession).toHaveBeenCalledTimes(2);
79+
expect(emitReasons).toHaveLength(1); // the session_start_retry event fired once
80+
});
81+
82+
it('4. transient then transient → throws the retry error (second failure surfaces)', async () => {
83+
const secondErr = new Error('TaskDefinition is inactive (again)');
84+
const startSession = jest
85+
.fn()
86+
.mockRejectedValueOnce(TRANSIENT)
87+
.mockRejectedValueOnce(secondErr);
88+
const { d } = deps();
89+
await expect(startSessionWithRetry({ startSession }, {} as never, d)).rejects.toBe(secondErr);
90+
expect(startSession).toHaveBeenCalledTimes(2);
91+
});
92+
});
93+
94+
describe('startSessionWithRetry — retry-event emit is best-effort (#599 B1)', () => {
95+
it('a telemetry failure does NOT abort or mis-attribute the retry', async () => {
96+
// The exact B1 scenario: the TaskEvents PutItem throws (throttle/timeout,
97+
// co-occurring with the transient session-start failure). The retry must
98+
// still run and succeed — the emit fault must not surface as the failure.
99+
const startSession = jest
100+
.fn()
101+
.mockRejectedValueOnce(TRANSIENT)
102+
.mockResolvedValueOnce(HANDLE);
103+
const emitErr = new Error('ProvisionedThroughputExceededException');
104+
const { d, warns } = deps({
105+
emitRetryEvent: async () => {
106+
throw emitErr;
107+
},
108+
});
109+
const res = await startSessionWithRetry({ startSession }, {} as never, d);
110+
// Retry proceeded and succeeded despite the emit throwing.
111+
expect(res).toEqual({ handle: HANDLE, autoRetried: true });
112+
expect(startSession).toHaveBeenCalledTimes(2);
113+
// The emit failure was logged (WARN), not propagated.
114+
expect(warns.some((w) => w.message.includes('event emit failed'))).toBe(true);
115+
});
116+
});

0 commit comments

Comments
 (0)