Skip to content

Commit cb92466

Browse files
committed
feat(errors): 3-way transient/service/user classification + auto-retry-once on transient session-start
Error handling couldn't distinguish a transient hiccup from a real service error — the retryable boolean conflated 'self-heals on retry' with 'you must change something first', so a customer couldn't tell whether to retry or call their admin. - error-classifier.ts: new ErrorClass axis (transient / service / user) on every classification. transient = infra/service hiccup a retry clears (ECS deploy-race, ENI/capacity delay, network blip, Bedrock throttle, concurrency cap); service = real platform/config fault an admin owns (bad token/scopes, model-not-enabled, blueprint misconfig); user = the request/code is the thing to change (build/test failed, guardrail, wrong PR, max-turns). retryGuidance() keys on errorClass and takes an autoRetried flag. New isTransientError() helper. - orchestrate-task.ts: AUTO-RETRY-ONCE at session-start for transient failures — the one place a retry is idempotent by construction (no clone/commits/PR yet). Emits session_start_retry; stamps '[auto-retried]' so the surface says 'I tried again, still failed'. Mid-run crashes are NOT retried. Backport note: the failure-reply.ts consumer hunk from the origin commit is omitted — that module is part of the not-yet-merged #247 orchestration UX and does not exist on main. The classifier axis + auto-retry stand alone. (cherry picked from commit 63a12dc)
1 parent e2b777a commit cb92466

3 files changed

Lines changed: 232 additions & 4 deletions

File tree

cdk/src/handlers/orchestrate-task.ts

Lines changed: 37 additions & 3 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 { classifyError, isTransientError } from './shared/error-classifier';
2324
import { reportIssueFailure as reportJiraIssueFailure } from './shared/jira-feedback';
2425
import { reportIssueFailure } from './shared/linear-feedback';
2526
import { logger } from './shared/logger';
@@ -159,14 +160,42 @@ const durableHandler: DurableExecutionHandler<OrchestrateTaskEvent, void> = asyn
159160
// Step 4: Start agent session — resolve compute strategy, invoke runtime, transition to RUNNING
160161
// Returns the full SessionHandle (serializable) so ECS polling can use it in step 5.
161162
const sessionHandle = await context.step('start-session', async () => {
163+
let autoRetried = false;
162164
try {
163165
const strategy = resolveComputeStrategy(blueprintConfig);
164-
const handle = await strategy.startSession({
166+
const startInput = {
165167
taskId,
166168
userId: task.user_id,
167169
payload,
168170
blueprintConfig,
169-
});
171+
};
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+
}
170199

171200
// Build compute metadata for the task record so cancel-task can stop the right backend
172201
const computeMetadata: Record<string, string> = handle.strategyType === 'ecs'
@@ -192,7 +221,12 @@ const durableHandler: DurableExecutionHandler<OrchestrateTaskEvent, void> = asyn
192221

193222
return handle;
194223
} catch (err) {
195-
await failTask(taskId, TaskStatus.HYDRATING, `Session start failed: ${String(err)}`, task.user_id, true, task.repo);
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.
228+
const retriedNote = autoRetried ? ' [auto-retried]' : '';
229+
await failTask(taskId, TaskStatus.HYDRATING, `Session start failed: ${String(err)}${retriedNote}`, task.user_id, true, task.repo);
196230
throw err;
197231
}
198232
});

0 commit comments

Comments
 (0)