Skip to content

Commit 5e139e3

Browse files
committed
fix(agents): centralize subagent timeout math
1 parent 620acaf commit 5e139e3

6 files changed

Lines changed: 92 additions & 55 deletions

src/agents/subagent-registry-lifecycle.ts

Lines changed: 1 addition & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ import {
5353
safeRemoveAttachmentsDir,
5454
} from "./subagent-registry-helpers.js";
5555
import type { PendingFinalDeliveryPayload, SubagentRunRecord } from "./subagent-registry.types.js";
56+
import { resolveSubagentRunDeadlineMs } from "./subagent-run-timeout.js";
5657
import { deleteSubagentSessionForCleanup } from "./subagent-session-cleanup.js";
5758

5859
type CaptureSubagentCompletionReply =
@@ -75,27 +76,6 @@ async function loadCleanupBrowserSessionsForLifecycleEnd(): Promise<
7576
return (await browserCleanupLoader.load()).cleanupBrowserSessionsForLifecycleEnd;
7677
}
7778

78-
function resolveSubagentRunDeadlineMs(
79-
entry: SubagentRunRecord,
80-
observedStartedAt?: number,
81-
): number | undefined {
82-
const timeoutSeconds = entry.runTimeoutSeconds;
83-
if (
84-
typeof timeoutSeconds !== "number" ||
85-
!Number.isFinite(timeoutSeconds) ||
86-
timeoutSeconds <= 0
87-
) {
88-
return undefined;
89-
}
90-
const startedAt =
91-
typeof observedStartedAt === "number" && Number.isFinite(observedStartedAt)
92-
? observedStartedAt
93-
: typeof entry.startedAt === "number" && Number.isFinite(entry.startedAt)
94-
? entry.startedAt
95-
: entry.createdAt;
96-
return Number.isFinite(startedAt) ? startedAt + Math.floor(timeoutSeconds * 1000) : undefined;
97-
}
98-
9979
function shouldPreservePublishedExplicitRunTimeout(params: { entry: SubagentRunRecord }): boolean {
10080
if (
10181
typeof params.entry.runTimeoutSeconds !== "number" ||

src/agents/subagent-registry-run-manager.ts

Lines changed: 1 addition & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import {
3535
safeRemoveAttachmentsDir,
3636
} from "./subagent-registry-helpers.js";
3737
import type { SubagentRunRecord } from "./subagent-registry.types.js";
38+
import { resolveSubagentRunDeadlineMs } from "./subagent-run-timeout.js";
3839
import type { SubagentSessionCompletion } from "./subagent-session-reconciliation.js";
3940

4041
const log = createSubsystemLogger("agents/subagent-registry");
@@ -45,30 +46,6 @@ function shouldDeleteAttachments(entry: SubagentRunRecord) {
4546
return entry.cleanup === "delete" || !entry.retainAttachmentsOnKeep;
4647
}
4748

48-
function resolveSubagentRunDeadlineMs(
49-
entry: SubagentRunRecord,
50-
observedStartedAt?: number,
51-
): number | undefined {
52-
const timeoutSeconds = entry.runTimeoutSeconds;
53-
if (
54-
typeof timeoutSeconds !== "number" ||
55-
!Number.isFinite(timeoutSeconds) ||
56-
timeoutSeconds <= 0
57-
) {
58-
return undefined;
59-
}
60-
const startedAt =
61-
typeof observedStartedAt === "number" && Number.isFinite(observedStartedAt)
62-
? observedStartedAt
63-
: typeof entry.startedAt === "number" && Number.isFinite(entry.startedAt)
64-
? entry.startedAt
65-
: entry.createdAt;
66-
if (!Number.isFinite(startedAt)) {
67-
return undefined;
68-
}
69-
return startedAt + Math.floor(timeoutSeconds * 1000);
70-
}
71-
7249
function resolveHardRunTimeoutEndedAt(
7350
entry: SubagentRunRecord,
7451
now: number,

src/agents/subagent-run-liveness.ts

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { SubagentRunRecord } from "./subagent-registry.types.js";
2+
import { resolveSubagentRunDurationMs } from "./subagent-run-timeout.js";
23
import { getSubagentSessionStartedAt } from "./subagent-session-metrics.js";
34

45
export const STALE_UNENDED_SUBAGENT_RUN_MS = 2 * 60 * 60 * 1_000;
@@ -13,12 +14,9 @@ export function hasSubagentRunEnded<T extends Pick<SubagentRunRecord, "endedAt">
1314
}
1415

1516
function resolveStaleCutoffMs(entry: Pick<SubagentRunRecord, "runTimeoutSeconds">): number {
16-
const timeoutSeconds = entry.runTimeoutSeconds;
17-
if (typeof timeoutSeconds === "number" && Number.isFinite(timeoutSeconds) && timeoutSeconds > 0) {
18-
return Math.max(
19-
STALE_UNENDED_SUBAGENT_RUN_MS,
20-
Math.floor(timeoutSeconds) * 1_000 + EXPLICIT_TIMEOUT_STALE_GRACE_MS,
21-
);
17+
const durationMs = resolveSubagentRunDurationMs(entry.runTimeoutSeconds);
18+
if (durationMs !== undefined) {
19+
return Math.max(STALE_UNENDED_SUBAGENT_RUN_MS, durationMs + EXPLICIT_TIMEOUT_STALE_GRACE_MS);
2220
}
2321
return STALE_UNENDED_SUBAGENT_RUN_MS;
2422
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { describe, expect, it } from "vitest";
2+
import { MAX_TIMER_TIMEOUT_MS } from "../shared/number-coercion.js";
3+
import {
4+
resolveSubagentRunDeadlineMs,
5+
resolveSubagentRunDurationMs,
6+
resolveSubagentRunTimerDelayMs,
7+
} from "./subagent-run-timeout.js";
8+
9+
describe("subagent run timeout helpers", () => {
10+
it("preserves semantic deadlines longer than the timer cap", () => {
11+
const thirtyDaysSeconds = 30 * 24 * 60 * 60;
12+
13+
expect(resolveSubagentRunDurationMs(thirtyDaysSeconds)).toBe(2_592_000_000);
14+
expect(
15+
resolveSubagentRunDeadlineMs({
16+
createdAt: 1_000,
17+
runTimeoutSeconds: thirtyDaysSeconds,
18+
}),
19+
).toBe(2_592_001_000);
20+
});
21+
22+
it("caps actual timer delays without shortening semantic durations", () => {
23+
const thirtyDaysSeconds = 30 * 24 * 60 * 60;
24+
25+
expect(resolveSubagentRunTimerDelayMs(thirtyDaysSeconds)).toBe(MAX_TIMER_TIMEOUT_MS);
26+
expect(resolveSubagentRunDurationMs(thirtyDaysSeconds)).toBeGreaterThan(MAX_TIMER_TIMEOUT_MS);
27+
});
28+
29+
it("ignores invalid timeout seconds and invalid start timestamps", () => {
30+
expect(resolveSubagentRunDurationMs(Number.NaN)).toBeUndefined();
31+
expect(resolveSubagentRunDurationMs(0)).toBeUndefined();
32+
expect(
33+
resolveSubagentRunDeadlineMs({
34+
createdAt: Number.POSITIVE_INFINITY,
35+
runTimeoutSeconds: 60,
36+
}),
37+
).toBeUndefined();
38+
});
39+
});

src/agents/subagent-run-timeout.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import {
2+
asDateTimestampMs,
3+
finiteSecondsToTimerSafeMilliseconds,
4+
} from "../shared/number-coercion.js";
5+
import type { SubagentRunRecord } from "./subagent-registry.types.js";
6+
7+
export function resolveSubagentRunTimerDelayMs(timeoutSeconds: unknown): number | undefined {
8+
return finiteSecondsToTimerSafeMilliseconds(timeoutSeconds, { floorSeconds: true });
9+
}
10+
11+
export function resolveSubagentRunDurationMs(timeoutSeconds: unknown): number | undefined {
12+
if (
13+
typeof timeoutSeconds !== "number" ||
14+
!Number.isFinite(timeoutSeconds) ||
15+
timeoutSeconds <= 0
16+
) {
17+
return undefined;
18+
}
19+
const durationMs = Math.floor(timeoutSeconds) * 1000;
20+
return Number.isSafeInteger(durationMs) && durationMs > 0 ? durationMs : undefined;
21+
}
22+
23+
export function resolveSubagentRunDeadlineMs(
24+
entry: Pick<SubagentRunRecord, "createdAt" | "startedAt" | "runTimeoutSeconds">,
25+
observedStartedAt?: number,
26+
): number | undefined {
27+
const durationMs = resolveSubagentRunDurationMs(entry.runTimeoutSeconds);
28+
if (durationMs === undefined) {
29+
return undefined;
30+
}
31+
const startedAt =
32+
typeof observedStartedAt === "number" && Number.isFinite(observedStartedAt)
33+
? observedStartedAt
34+
: typeof entry.startedAt === "number" && Number.isFinite(entry.startedAt)
35+
? entry.startedAt
36+
: entry.createdAt;
37+
const safeStartedAt = asDateTimestampMs(startedAt);
38+
if (safeStartedAt === undefined) {
39+
return undefined;
40+
}
41+
const deadlineMs = safeStartedAt + durationMs;
42+
return Number.isSafeInteger(deadlineMs) && asDateTimestampMs(deadlineMs) !== undefined
43+
? deadlineMs
44+
: undefined;
45+
}

src/agents/subagent-spawn.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ import { resolveSubagentCapabilities } from "./subagent-capabilities.js";
5454
import { getSubagentDepthFromSessionStore } from "./subagent-depth.js";
5555
import { buildSubagentInitialUserMessage } from "./subagent-initial-user-message.js";
5656
import { countActiveRunsForSession, registerSubagentRun } from "./subagent-registry.js";
57+
import { resolveSubagentRunTimerDelayMs } from "./subagent-run-timeout.js";
5758
import { resolveSubagentSpawnAcceptedNote } from "./subagent-spawn-accepted-note.js";
5859
import { resolveSubagentSpawnOwnership } from "./subagent-spawn-ownership.js";
5960
import { resolveSubagentTargetPolicy } from "./subagent-target-policy.js";
@@ -261,10 +262,7 @@ function buildResolvedSubagentModelMetadata(
261262
}
262263

263264
function resolveSubagentAgentGatewayTimeoutMs(runTimeoutSeconds: number): number {
264-
const runTimeoutMs =
265-
Number.isFinite(runTimeoutSeconds) && runTimeoutSeconds > 0
266-
? Math.floor(runTimeoutSeconds * 1000)
267-
: 0;
265+
const runTimeoutMs = resolveSubagentRunTimerDelayMs(runTimeoutSeconds) ?? 0;
268266
if (runTimeoutMs <= 0) {
269267
return DEFAULT_SUBAGENT_AGENT_GATEWAY_TIMEOUT_MS;
270268
}

0 commit comments

Comments
 (0)