Skip to content

Commit efcd974

Browse files
committed
fix: cap live task runtime with absolute ceiling
1 parent f79e5a2 commit efcd974

8 files changed

Lines changed: 153 additions & 1 deletion

File tree

.claude/skills/env-reference/SKILL.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ See `apps/api/.env.example` for the full list. Key variables:
113113
- `TASK_RECONCILIATION_PROMPT_SOFT_STALL_MS` — In-flight prompt observation threshold before SAM records a non-interrupting reconciliation event (default: 1800000)
114114
- `TASK_RECONCILIATION_PROMPT_HARD_STALL_MS` — In-flight prompt hard-stall threshold before SAM requests prompt cancellation and retries check-in later (default: 7200000)
115115
- `TASK_RECONCILIATION_MIN_ALARM_DELAY_MS` — Minimum delay before the next reconciliation alarm can fire (default: 10000)
116+
- `TASK_RUN_ABSOLUTE_CEILING_MS` — Absolute runaway-cost ceiling that fails even a demonstrably live task (default: 86400000 / 24h)
116117
- `SESSION_ACTIVITY_STALE_THRESHOLD_MS` — Evidence-based fallback threshold before stale working activity can be healed to idle (default: 300000)
117118
- `NODE_HEARTBEAT_STALE_SECONDS` — Staleness threshold for node health
118119
- `NODE_AGENT_READY_TIMEOUT_MS` — Max wait for freshly provisioned node-agent health

apps/api/.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,7 @@ BASE_DOMAIN=workspaces.example.com
350350
# STUCK_TASK_MAX_CANDIDATES_PER_SWEEP=100 # Bound task rows inspected per cron invocation
351351
# TASK_LIVENESS_MAX_ACP_SESSIONS=5 # Bound task-scoped ACP sessions inspected per candidate
352352
# TASK_LIVENESS_PROBE_TIMEOUT_MS=5000 # Per-candidate timeout for the ACP liveness DO probe (inconclusive on timeout, never fatal)
353+
# TASK_RUN_ABSOLUTE_CEILING_MS=86400000 # 24h — absolute runaway-cost ceiling; fails even a live task
353354
# CLAUDE_CODE_COMPACTION_LOOP_DETECTOR_ENABLED=true
354355
# CLAUDE_CODE_COMPACTION_LOOP_RECENT_MESSAGE_LIMIT=40
355356
# CLAUDE_CODE_COMPACTION_LOOP_WINDOW_MESSAGES=20

apps/api/src/env.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,7 @@ export interface Env {
221221
STUCK_TASK_MAX_CANDIDATES_PER_SWEEP?: string;
222222
TASK_LIVENESS_MAX_ACP_SESSIONS?: string;
223223
TASK_LIVENESS_PROBE_TIMEOUT_MS?: string;
224+
TASK_RUN_ABSOLUTE_CEILING_MS?: string;
224225
CLAUDE_CODE_COMPACTION_LOOP_DETECTOR_ENABLED?: string;
225226
CLAUDE_CODE_COMPACTION_LOOP_RECENT_MESSAGE_LIMIT?: string;
226227
CLAUDE_CODE_COMPACTION_LOOP_WINDOW_MESSAGES?: string;

apps/api/src/scheduled/stuck-tasks.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
DEFAULT_TASK_DO_MISMATCH_GRACE_MS,
2929
DEFAULT_TASK_LIVENESS_MAX_ACP_SESSIONS,
3030
DEFAULT_TASK_LIVENESS_PROBE_TIMEOUT_MS,
31+
DEFAULT_TASK_RUN_ABSOLUTE_CEILING_MS,
3132
DEFAULT_TASK_RUN_HARD_TIMEOUT_MS,
3233
DEFAULT_TASK_RUN_MAX_EXECUTION_MS,
3334
DEFAULT_TASK_STUCK_DELEGATED_TIMEOUT_MS,
@@ -304,6 +305,7 @@ export async function recoverStuckTasks(env: Env): Promise<StuckTaskResult> {
304305
const delegatedTimeoutMs = parseMs(env.TASK_STUCK_DELEGATED_TIMEOUT_MS, DEFAULT_TASK_STUCK_DELEGATED_TIMEOUT_MS);
305306
const maxExecutionMs = parseMs(env.TASK_RUN_MAX_EXECUTION_MS, DEFAULT_TASK_RUN_MAX_EXECUTION_MS);
306307
const hardTimeoutMs = parseMs(env.TASK_RUN_HARD_TIMEOUT_MS, DEFAULT_TASK_RUN_HARD_TIMEOUT_MS);
308+
const absoluteCeilingMs = parseMs(env.TASK_RUN_ABSOLUTE_CEILING_MS, DEFAULT_TASK_RUN_ABSOLUTE_CEILING_MS);
307309
const mismatchGraceMs = parseMs(env.TASK_DO_MISMATCH_GRACE_MS, DEFAULT_TASK_DO_MISMATCH_GRACE_MS);
308310
const maxCandidates = parseMs(env.STUCK_TASK_MAX_CANDIDATES_PER_SWEEP, DEFAULT_STUCK_TASK_MAX_CANDIDATES_PER_SWEEP);
309311

@@ -315,6 +317,13 @@ export async function recoverStuckTasks(env: Env): Promise<StuckTaskResult> {
315317
});
316318
}
317319

320+
if (absoluteCeilingMs <= hardTimeoutMs) {
321+
log.warn('stuck_task.misconfigured_absolute_ceiling', {
322+
absoluteCeilingMs,
323+
hardTimeoutMs,
324+
});
325+
}
326+
318327
// Find stuck tasks via raw SQL — include workspace_id and auto_provisioned_node_id
319328
// for diagnostic context capture.
320329
const stuckTasks = await env.DATABASE.prepare(
@@ -409,6 +418,11 @@ export async function recoverStuckTasks(env: Env): Promise<StuckTaskResult> {
409418
const startedAt = task.started_at ? new Date(task.started_at).getTime() : updatedAt;
410419
const executionMs = now.getTime() - startedAt;
411420
if (executionMs > maxExecutionMs) {
421+
if (executionMs > absoluteCeilingMs) {
422+
isStuck = true;
423+
reason = `Task exceeded the absolute runaway-cost ceiling of ${Math.round(absoluteCeilingMs / 60000)} minutes; live-runtime tasks are bounded to prevent unbounded compute.${stepInfo}`;
424+
break;
425+
}
412426
const liveness = await probeLiveness();
413427
if (liveness.live || !liveness.conclusive) {
414428
if (liveness.live) {

apps/api/tests/unit/stuck-tasks.test.ts

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -558,6 +558,136 @@ describe('recoverStuckTasks', () => {
558558
});
559559
});
560560

561+
describe('absolute runaway-cost ceiling', () => {
562+
it('fails a live task past the absolute runaway-cost ceiling without probing liveness', async () => {
563+
const now = Date.now();
564+
const startedAt = new Date(now - 25 * 60 * 60 * 1000).toISOString();
565+
const recentHeartbeat = new Date(now - 30 * 1000).toISOString();
566+
const responses = new Map<string, { results: unknown[]; changes?: number }>();
567+
responses.set("status IN ('queued', 'delegated', 'in_progress')", {
568+
results: [
569+
{
570+
id: 'task-absolute-ceiling',
571+
project_id: 'proj-1',
572+
user_id: 'user-1',
573+
status: 'in_progress',
574+
execution_step: 'running',
575+
updated_at: startedAt,
576+
started_at: startedAt,
577+
workspace_id: 'ws-1',
578+
auto_provisioned_node_id: 'node-1',
579+
},
580+
],
581+
});
582+
responses.set('w.chat_session_id', {
583+
results: [
584+
{
585+
workspace_status: 'running',
586+
chat_session_id: 'chat-1',
587+
node_id: 'node-1',
588+
node_status: 'running',
589+
health_status: 'healthy',
590+
last_heartbeat_at: recentHeartbeat,
591+
},
592+
],
593+
});
594+
responses.set('node_id, status FROM workspaces', {
595+
results: [{ id: 'ws-1', node_id: 'node-1', status: 'running' }],
596+
});
597+
responses.set('status, health_status FROM nodes', {
598+
results: [{ id: 'node-1', status: 'running', health_status: 'healthy' }],
599+
});
600+
responses.set("UPDATE tasks SET status = 'failed'", { results: [], changes: 1 });
601+
const env = createMockEnv(responses);
602+
const result = await recoverStuckTasks(env);
603+
expect(result.failedInProgress).toBe(1);
604+
expect(result.heartbeatSkipped).toBe(0);
605+
expect(projectDataMocks.listAcpSessions).not.toHaveBeenCalled();
606+
expect(syncTriggerExecutionMock).toHaveBeenCalledWith(
607+
env.DATABASE,
608+
'task-absolute-ceiling',
609+
'failed',
610+
expect.stringContaining('absolute runaway-cost ceiling')
611+
);
612+
});
613+
614+
it('preserves a live task below the absolute runaway-cost ceiling', async () => {
615+
const now = Date.now();
616+
const startedAt = new Date(now - 23 * 60 * 60 * 1000).toISOString();
617+
const recentHeartbeat = new Date(now - 30 * 1000).toISOString();
618+
const responses = new Map<string, { results: unknown[]; changes?: number }>();
619+
responses.set("status IN ('queued', 'delegated', 'in_progress')", {
620+
results: [
621+
{
622+
id: 'task-below-absolute-ceiling',
623+
project_id: 'proj-1',
624+
user_id: 'user-1',
625+
status: 'in_progress',
626+
execution_step: 'running',
627+
updated_at: startedAt,
628+
started_at: startedAt,
629+
workspace_id: 'ws-1',
630+
auto_provisioned_node_id: 'node-1',
631+
},
632+
],
633+
});
634+
responses.set('w.chat_session_id', {
635+
results: [
636+
{
637+
workspace_status: 'running',
638+
chat_session_id: 'chat-1',
639+
node_id: 'node-1',
640+
node_status: 'running',
641+
health_status: 'healthy',
642+
last_heartbeat_at: recentHeartbeat,
643+
},
644+
],
645+
});
646+
const result = await recoverStuckTasks(createMockEnv(responses));
647+
expect(result.failedInProgress).toBe(0);
648+
expect(result.heartbeatSkipped).toBe(1);
649+
});
650+
651+
it('falls back to the default absolute ceiling for an invalid zero value', async () => {
652+
const now = Date.now();
653+
const startedAt = new Date(now - 23 * 60 * 60 * 1000).toISOString();
654+
const recentHeartbeat = new Date(now - 30 * 1000).toISOString();
655+
const responses = new Map<string, { results: unknown[]; changes?: number }>();
656+
responses.set("status IN ('queued', 'delegated', 'in_progress')", {
657+
results: [
658+
{
659+
id: 'task-invalid-absolute-ceiling',
660+
project_id: 'proj-1',
661+
user_id: 'user-1',
662+
status: 'in_progress',
663+
execution_step: 'running',
664+
updated_at: startedAt,
665+
started_at: startedAt,
666+
workspace_id: 'ws-1',
667+
auto_provisioned_node_id: 'node-1',
668+
},
669+
],
670+
});
671+
responses.set('w.chat_session_id', {
672+
results: [
673+
{
674+
workspace_status: 'running',
675+
chat_session_id: 'chat-1',
676+
node_id: 'node-1',
677+
node_status: 'running',
678+
health_status: 'healthy',
679+
last_heartbeat_at: recentHeartbeat,
680+
},
681+
],
682+
});
683+
const result = await recoverStuckTasks(
684+
createMockEnv(responses, { TASK_RUN_ABSOLUTE_CEILING_MS: '0' })
685+
);
686+
expect(result.failedInProgress).toBe(0);
687+
expect(result.heartbeatSkipped).toBe(1);
688+
});
689+
});
690+
561691
describe('hard timeout enforcement', () => {
562692
it('preserves a genuinely live task past the hard timeout', async () => {
563693
const now = Date.now();

apps/www/src/content/docs/docs/reference/configuration.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,7 @@ SAM loads OpenCode Zen and OpenCode Go model choices through the authenticated m
207207
| `STUCK_TASK_MAX_CANDIDATES_PER_SWEEP` | `100` | Maximum active tasks inspected by each recovery sweep |
208208
| `TASK_LIVENESS_MAX_ACP_SESSIONS` | `5` | Maximum task-scoped ACP sessions inspected per liveness probe |
209209
| `TASK_LIVENESS_PROBE_TIMEOUT_MS` | `5000` (5 sec) | Per-candidate timeout for the ACP liveness probe; a timeout is inconclusive (never fails a task) |
210+
| `TASK_RUN_ABSOLUTE_CEILING_MS` | `86400000` (24 hr) | Absolute runaway-cost ceiling; fails even a task with a demonstrably live runtime |
210211
| `CLAUDE_CODE_COMPACTION_LOOP_DETECTOR_ENABLED` | `true` | Enable Claude Code compaction-loop shutdown from recent message evidence |
211212
| `CLAUDE_CODE_COMPACTION_LOOP_RECENT_MESSAGE_LIMIT` | `40` | Recent task-session messages to inspect for compaction-loop evidence |
212213
| `CLAUDE_CODE_COMPACTION_LOOP_WINDOW_MESSAGES` | `20` | Rolling recent-message window used for compaction-loop detection |
@@ -220,7 +221,7 @@ SAM loads OpenCode Zen and OpenCode Go model choices through the authenticated m
220221
| `TASK_RECONCILIATION_PROMPT_HARD_STALL_MS` | `7200000` (2 hr) | In-flight prompt hard-stall threshold before SAM requests prompt cancellation |
221222
| `TASK_RECONCILIATION_MIN_ALARM_DELAY_MS` | `10000` (10 sec) | Minimum delay before the next reconciliation alarm can fire |
222223

223-
> **Liveness-gated recovery.** Stuck-task recovery for `in_progress` tasks (including task-mode work paused at the `awaiting_followup` execution step) is gated on **task-scoped** liveness — a live workspace, a healthy node with a recent heartbeat, **and** an active task-scoped ACP session. A shared-node heartbeat alone is never sufficient. Consequently, `TASK_RUN_HARD_TIMEOUT_MS` and `TASK_RUN_MAX_EXECUTION_MS` bound the point at which a task with **no** proven live runtime is failed; a task with a demonstrably live runtime is preserved past those thresholds rather than terminated on elapsed time alone. When liveness cannot be determined (probe timeout or error), the task is left untouched (fail-safe).
224+
> **Liveness-gated recovery.** Stuck-task recovery for `in_progress` tasks (including task-mode work paused at the `awaiting_followup` execution step) is gated on **task-scoped** liveness — a live workspace, a healthy node with a recent heartbeat, **and** an active task-scoped ACP session. A shared-node heartbeat alone is never sufficient. Consequently, `TASK_RUN_HARD_TIMEOUT_MS` and `TASK_RUN_MAX_EXECUTION_MS` bound the point at which a task with **no** proven live runtime is failed; a task with a demonstrably live runtime is preserved past those thresholds, but remains bounded by `TASK_RUN_ABSOLUTE_CEILING_MS` (24 hours by default) as a runaway-cost backstop. When liveness cannot be determined (probe timeout or error), the task is left untouched (fail-safe) until it reaches that absolute ceiling.
224225
225226
## Node & Workspace Readiness
226227

packages/shared/src/constants/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ export {
7373
DEFAULT_TASK_DO_MISMATCH_GRACE_MS,
7474
DEFAULT_TASK_LIVENESS_MAX_ACP_SESSIONS,
7575
DEFAULT_TASK_LIVENESS_PROBE_TIMEOUT_MS,
76+
DEFAULT_TASK_RUN_ABSOLUTE_CEILING_MS,
7677
DEFAULT_TASK_RUN_CLEANUP_DELAY_MS,
7778
DEFAULT_TASK_RUN_HARD_TIMEOUT_MS,
7879
DEFAULT_TASK_RUN_MAX_EXECUTION_MS,

packages/shared/src/constants/task-execution.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@ export const DEFAULT_TASK_RUN_MAX_EXECUTION_MS = 4 * 60 * 60 * 1000; // 4 hours
3030
* Override via TASK_RUN_HARD_TIMEOUT_MS env var. */
3131
export const DEFAULT_TASK_RUN_HARD_TIMEOUT_MS = 8 * 60 * 60 * 1000; // 8 hours
3232

33+
/** Absolute runaway-cost backstop (ms) that bounds even demonstrably live tasks. */
34+
export const DEFAULT_TASK_RUN_ABSOLUTE_CEILING_MS = 24 * 60 * 60 * 1000;
35+
3336
/** Default threshold (ms) for a task stuck in 'queued' status. Override via TASK_STUCK_QUEUED_TIMEOUT_MS env var.
3437
* Must be > TASK_RUNNER_AGENT_READY_TIMEOUT_MS (15 min) to avoid the stuck-task cron killing tasks
3538
* that are legitimately waiting for cloud-init to finish. Cloud-init takes 8-12 min on Hetzner.

0 commit comments

Comments
 (0)