From 23e0541d9b9b44a33d07e6a998eeb945975abbe2 Mon Sep 17 00:00:00 2001 From: Masa <98894508+Masashi-Ono0611@users.noreply.github.com> Date: Tue, 21 Jul 2026 05:56:00 +0900 Subject: [PATCH] =?UTF-8?q?fix(cycle):=20budget=20the=20patterns=20subagen?= =?UTF-8?q?t=20from=20remaining=20job=20time=20=E2=80=94=20one=20phase's?= =?UTF-8?q?=20fixed=2035-min=20worst=20case=20defeats=20any=20interval-der?= =?UTF-8?q?ived=20cycle=20budget=20(#2781)=20(#2959)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(cycle): budget the patterns subagent from remaining job time, not a fixed constant (#2781) The autopilot-cycle job gets an interval-derived timeout stamped at submit, but the patterns phase submits its subagent with a fixed 30-min job timeout and waits up to 35 min — one phase's worst case exceeds ANY interval-derived budget <= 35 min, so the parent job dead-letters mid-patterns and the tail phases (consolidate -> schema-suggest) starve for days (#2781, the deeper half left open by the #2852 dispatch-floor fix). - MinionJobContext.deadlineAtMs: absolute deadline from the claim-time timeout_at stamp (the DB ground truth handleTimeouts() sweeps against; re-stamped on every claim so retries get a fresh budget). Null when the job has no per-job timeout. - worker: the per-job abort timer now derives its delay from timeout_at when present, so the in-process timer, the DB sweeper, and the handler-visible deadline agree on ONE absolute instant. - autopilot-cycle + autopilot-global-maintenance handlers thread deadlineAtMs into runCycle; CycleOpts carries it to the patterns phase. - patterns: clampSubagentBudgets() derives BOTH the child job timeout and the wait timeout from the same child deadline (parent deadline minus a 60s stop-margin reserve — enough for the wait poll + force-evict grace + cleanup, deliberately NOT a promise that tail phases complete). Under a 2-min minimum the phase skips honestly (insufficient_cycle_budget) instead of submitting a guaranteed-kill LLM call; the next cycle retries with a fresh budget. - Direct callers (gbrain dream) pass no deadline and keep the configured timeouts unchanged. Follow-up (separate PR): synthesize has the same shape plus sequential per-child waits that accumulate N x subagent_wait_timeout_ms past any parent budget; it needs per-wait remaining-time recomputation. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01RaNDjecPPvhC7LRqkxkhGF * fix(cycle): address review — cancel timed-out patterns child; thread deadline through phase-wrapper handlers - P1: the child's timeout_ms clock starts at ITS claim, so a queued child could outlive the parent deadline the wait was clamped to. On wait timeout, cancelJob strips it (waiting -> cancelled; active -> lock stripped, worker abort fires next renew tick). - P2: makePhaseHandler (standalone patterns/synthesize/... minion jobs) now threads job.deadlineAtMs into runCycle like the autopilot handlers. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01RaNDjecPPvhC7LRqkxkhGF --------- Co-authored-by: Claude Fable 5 --- src/commands/jobs.ts | 3 + src/core/cycle.ts | 11 ++ src/core/cycle/patterns.ts | 82 ++++++++- src/core/minions/types.ts | 6 + src/core/minions/worker.ts | 12 +- test/cycle-patterns-deadline-budget.test.ts | 168 ++++++++++++++++++ test/e2e/ingestion-roundtrip.test.ts | 1 + ...bagent-crash-replay-multi-provider.test.ts | 1 + test/e2e/subagent-gateway-path.test.ts | 1 + ...gent-gateway-resume-reconciliation.test.ts | 2 +- test/handlers-embed-backfill.test.ts | 1 + test/ingestion/ingest-capture.test.ts | 1 + test/minions-shell.test.ts | 1 + test/subagent-aggregator.test.ts | 1 + test/subagent-handler.test.ts | 1 + 15 files changed, 285 insertions(+), 7 deletions(-) create mode 100644 test/cycle-patterns-deadline-budget.test.ts diff --git a/src/commands/jobs.ts b/src/commands/jobs.ts index 52ba691ce..46f1027f5 100644 --- a/src/commands/jobs.ts +++ b/src/commands/jobs.ts @@ -1796,6 +1796,7 @@ export async function registerBuiltinHandlers( brainDir: effectiveBrainDir, pull, signal: job.signal, // propagate abort so cycle bails on timeout/cancel + deadlineAtMs: job.deadlineAtMs, // #2781: phases budget sub-work from remaining time ...(sourceId ? { sourceId } : {}), ...(requestedPhases && requestedPhases.length > 0 ? { phases: requestedPhases as any } : {}), yieldBetweenPhases: async () => { @@ -1833,6 +1834,7 @@ export async function registerBuiltinHandlers( brainDir: repoPath, pull: false, // brain-wide DB/maintenance work never git-pulls signal: job.signal, + deadlineAtMs: job.deadlineAtMs, // #2781: phases budget sub-work from remaining time phases, yieldBetweenPhases: async () => { await new Promise((r) => setImmediate(r)); }, }); @@ -1978,6 +1980,7 @@ export async function registerBuiltinHandlers( brainDir: repoPath, phases: [phase as any], signal: job.signal, + deadlineAtMs: job.deadlineAtMs, // #2781: phases budget sub-work from remaining time }); return { phase, status: report.status, report }; }; diff --git a/src/core/cycle.ts b/src/core/cycle.ts index a52efcd89..100eab2a3 100644 --- a/src/core/cycle.ts +++ b/src/core/cycle.ts @@ -479,6 +479,16 @@ export interface CycleOpts { * Validated via `assertValidSourceId` in `cycleLockIdFor` (defense-in-depth). */ sourceId?: string; + /** + * Absolute wall-clock deadline (epoch ms) of the enclosing minion job, + * from `MinionJobContext.deadlineAtMs` (the claim-time `timeout_at` + * stamp). Phases that spawn bounded sub-work (patterns' subagent) clamp + * their own timeouts to the REMAINING time so one phase's fixed + * worst-case can't blow past the job budget and dead-letter the whole + * cycle mid-phase (#2781). Unset for direct callers (`gbrain dream`) — + * phases then use their configured timeouts unchanged. + */ + deadlineAtMs?: number | null; } // ─── Lock primitives ─────────────────────────────────────────────── @@ -1888,6 +1898,7 @@ export async function runCycle( brainDir, dryRun, yieldDuringPhase: opts.yieldDuringPhase, + deadlineAtMs: opts.deadlineAtMs ?? null, })); result.duration_ms = duration_ms; phaseResults.push(result); diff --git a/src/core/cycle/patterns.ts b/src/core/cycle/patterns.ts index d96584dad..788381a63 100644 --- a/src/core/cycle/patterns.ts +++ b/src/core/cycle/patterns.ts @@ -37,6 +37,57 @@ export interface PatternsPhaseOpts { brainDir: string; dryRun: boolean; yieldDuringPhase?: () => Promise; + /** + * Absolute deadline (epoch ms) of the enclosing minion job, or null for + * direct callers (`gbrain dream`). When set, the subagent's job timeout + * and the wait timeout are clamped so the phase finishes (or times out) + * BEFORE the parent job's budget expires — a fixed 30/35-min default + * inside an interval-derived cycle budget dead-letters the whole cycle + * mid-phase and starves every tail phase (#2781). + */ + deadlineAtMs?: number | null; +} + +/** + * Stop-margin reserved under the parent deadline when clamping subagent + * budgets. NOT a promise that tail phases complete — the cycle is allowed + * to go partial and resume next tick. This only guarantees the phase's + * wait returns and the handler unwinds cleanly before the worker's abort + * fires: wait poll interval (5s) + worker force-evict grace (30s) + lock + * and DB cleanup headroom. + */ +export const CYCLE_DEADLINE_RESERVE_MS = 60 * 1000; + +/** + * Smallest remaining budget worth submitting a subagent for. Below this, + * the LLM call is near-certain to be killed mid-flight — wasted spend and + * a guaranteed-timeout child — so the phase skips honestly instead + * (`insufficient_cycle_budget`) and the next cycle retries with a fresh + * budget. + */ +export const MIN_PATTERNS_SUBAGENT_BUDGET_MS = 2 * 60 * 1000; + +/** + * Clamp the configured subagent budgets to the remaining parent-job time. + * Both timeouts derive from the SAME absolute child deadline + * (`deadlineAtMs - reserve`) so the child job's kill switch and our wait + * agree. Returns null when the remaining budget is below the minimum — + * caller should skip the phase without submitting. + */ +export function clampSubagentBudgets( + config: { subagentTimeoutMs: number; subagentWaitTimeoutMs: number }, + deadlineAtMs: number | null | undefined, + nowMs: number, +): { timeoutMs: number; waitTimeoutMs: number } | null { + if (deadlineAtMs == null) { + return { timeoutMs: config.subagentTimeoutMs, waitTimeoutMs: config.subagentWaitTimeoutMs }; + } + const childBudgetMs = deadlineAtMs - CYCLE_DEADLINE_RESERVE_MS - nowMs; + if (childBudgetMs < MIN_PATTERNS_SUBAGENT_BUDGET_MS) return null; + return { + timeoutMs: Math.min(config.subagentTimeoutMs, childBudgetMs), + waitTimeoutMs: Math.min(config.subagentWaitTimeoutMs, childBudgetMs), + }; } export async function runPhasePatterns( @@ -90,6 +141,19 @@ export async function runPhasePatterns( 'skills/_brain-filing-rules.json missing dream_synthesize_paths.globs')); } + // #2781: budget the subagent from the REMAINING parent-job time, not + // the fixed config default. Checked after the cheap gates (disabled / + // insufficient_evidence / no_provider) so a skip for budget reasons + // only fires when the phase would otherwise have submitted. + const budgets = clampSubagentBudgets(config, opts.deadlineAtMs, Date.now()); + if (budgets === null) { + return skipped( + 'insufficient_cycle_budget', + `remaining cycle budget under ${Math.round(MIN_PATTERNS_SUBAGENT_BUDGET_MS / 1000)}s ` + + `(reserve ${Math.round(CYCLE_DEADLINE_RESERVE_MS / 1000)}s); next cycle retries with a fresh budget`, + ); + } + const queue = new MinionQueue(engine); const data: SubagentHandlerData = { prompt: buildPatternsPrompt(reflections, config.minEvidence, config.outputRoot), @@ -99,7 +163,7 @@ export async function runPhasePatterns( }; const submitOpts: Partial = { max_stalled: 3, - timeout_ms: config.subagentTimeoutMs, + timeout_ms: budgets.timeoutMs, }; const job = await queue.add('subagent', data as unknown as Record, submitOpts, { allowProtectedSubmit: true, @@ -108,13 +172,23 @@ export async function runPhasePatterns( let outcome: string; try { const final = await waitForCompletion(queue, job.id, { - timeoutMs: config.subagentWaitTimeoutMs, + timeoutMs: budgets.waitTimeoutMs, pollMs: 5 * 1000, }); outcome = final.status; } catch (e) { - if (e instanceof TimeoutError) outcome = 'timeout'; - else throw e; + if (e instanceof TimeoutError) { + outcome = 'timeout'; + // The child's own timeout_ms clock starts at ITS claim, not at + // submit — a child that sat queued behind other work can outlive + // the parent deadline this wait was clamped to. Cancel it so the + // subagent can't keep spending/writing after the phase gave up + // (waiting child → cancelled immediately; active child → lock + // stripped, worker abort fires on next renew tick). + try { await queue.cancelJob(job.id); } catch { /* best-effort */ } + } else { + throw e; + } } if (opts.yieldDuringPhase) { diff --git a/src/core/minions/types.ts b/src/core/minions/types.ts index 73877bb99..7a1b5f08e 100644 --- a/src/core/minions/types.ts +++ b/src/core/minions/types.ts @@ -200,6 +200,12 @@ export interface MinionJobContext { attempts_made: number; /** AbortSignal for cooperative cancellation (fires on timeout, cancel, pause, or lock loss). */ signal: AbortSignal; + /** Absolute wall-clock deadline (epoch ms) from the claim-time `timeout_at` stamp, + * or null when the job has no per-job timeout. This is the DB's ground truth — + * the same instant handleTimeouts() dead-letters against — so handlers that + * spawn bounded sub-work (e.g. autopilot-cycle's subagent phases) can budget + * from the REMAINING time instead of a fixed constant that may exceed it. */ + deadlineAtMs: number | null; /** AbortSignal that fires only on worker process SIGTERM/SIGINT. Handlers sensitive * to deploy restarts (e.g. the shell handler, which must run a SIGTERM → 5s → SIGKILL * sequence on its child) listen to this in addition to `signal`. Most handlers can diff --git a/src/core/minions/worker.ts b/src/core/minions/worker.ts index beb3055ba..3117eed34 100644 --- a/src/core/minions/worker.ts +++ b/src/core/minions/worker.ts @@ -900,15 +900,22 @@ export class MinionWorker extends EventEmitter { // Per-job wall-clock timeout (timer-armed only if `timeout_ms` was // set on the job; the grace-evict pattern above now lives outside - // this branch). + // this branch). The delay derives from the claim-time `timeout_at` + // stamp when present so this timer, the DB sweeper (handleTimeouts), + // and the handler-visible `deadlineAtMs` all agree on ONE absolute + // deadline instead of three clocks started at slightly different + // instants. let timeoutTimer: ReturnType | null = null; if (job.timeout_ms != null) { + const delayMs = job.timeout_at != null + ? Math.max(0, job.timeout_at.getTime() - Date.now()) + : job.timeout_ms; timeoutTimer = setTimeout(() => { if (!abort.signal.aborted) { console.warn(`Job ${job.id} (${job.name}) hit per-job timeout (${job.timeout_ms}ms), aborting`); abort.abort(new Error('timeout')); } - }, job.timeout_ms); + }, delayMs); } const promise = this.executeJob(job, lockToken, abort, lockTimer) @@ -964,6 +971,7 @@ export class MinionWorker extends EventEmitter { data: job.data, attempts_made: job.attempts_made, signal: abort.signal, + deadlineAtMs: job.timeout_at != null ? job.timeout_at.getTime() : null, shutdownSignal: this.shutdownAbort.signal, updateProgress: async (progress: unknown) => { await this.queue.updateProgress(job.id, lockToken, progress); diff --git a/test/cycle-patterns-deadline-budget.test.ts b/test/cycle-patterns-deadline-budget.test.ts new file mode 100644 index 000000000..62bdf6ffe --- /dev/null +++ b/test/cycle-patterns-deadline-budget.test.ts @@ -0,0 +1,168 @@ +/** + * #2781 — patterns phase budgets its subagent from the REMAINING parent-job + * time instead of a fixed 30/35-min default that can exceed any + * interval-derived cycle budget and dead-letter the whole cycle mid-phase. + * + * Layers: + * 1. Unit tests on the exported pure `clampSubagentBudgets`. + * 2. A real-queue check that `claim` stamps `timeout_at` (the DB ground + * truth `deadlineAtMs` derives from) and leaves it null when the job + * has no per-job timeout. + * 3. Structural assertions pinning the wiring: worker → context → + * handler → runCycle → patterns (matches the house style of + * test/cycle-patterns.test.ts). + */ + +import { describe, test, expect, beforeAll, afterAll } from 'bun:test'; +import { readFileSync } from 'fs'; +import { PGLiteEngine } from '../src/core/pglite-engine.ts'; +import { MinionQueue } from '../src/core/minions/queue.ts'; +import { + clampSubagentBudgets, + CYCLE_DEADLINE_RESERVE_MS, + MIN_PATTERNS_SUBAGENT_BUDGET_MS, +} from '../src/core/cycle/patterns.ts'; + +const CONFIG = { + subagentTimeoutMs: 30 * 60 * 1000, + subagentWaitTimeoutMs: 35 * 60 * 1000, +}; + +describe('clampSubagentBudgets', () => { + const now = 1_000_000_000_000; // fixed epoch ms; the function takes nowMs explicitly + + test('null deadline → config passthrough (direct `gbrain dream` back-compat)', () => { + expect(clampSubagentBudgets(CONFIG, null, now)).toEqual({ + timeoutMs: CONFIG.subagentTimeoutMs, + waitTimeoutMs: CONFIG.subagentWaitTimeoutMs, + }); + expect(clampSubagentBudgets(CONFIG, undefined, now)).toEqual({ + timeoutMs: CONFIG.subagentTimeoutMs, + waitTimeoutMs: CONFIG.subagentWaitTimeoutMs, + }); + }); + + test('deadline far away → config values win (no clamping)', () => { + const deadline = now + 2 * 60 * 60 * 1000; // 2h out + expect(clampSubagentBudgets(CONFIG, deadline, now)).toEqual({ + timeoutMs: CONFIG.subagentTimeoutMs, + waitTimeoutMs: CONFIG.subagentWaitTimeoutMs, + }); + }); + + test('deadline inside config window → BOTH timeouts clamp to the same child budget', () => { + const deadline = now + 10 * 60 * 1000; // 10 min out + const childBudget = deadline - CYCLE_DEADLINE_RESERVE_MS - now; // 9 min + const budgets = clampSubagentBudgets(CONFIG, deadline, now); + expect(budgets).toEqual({ timeoutMs: childBudget, waitTimeoutMs: childBudget }); + // The child's own kill switch never outlives the parent budget. + expect(budgets!.timeoutMs).toBeLessThanOrEqual(deadline - now); + }); + + test('remaining budget below minimum → null (caller skips, no submit)', () => { + const deadline = now + CYCLE_DEADLINE_RESERVE_MS + MIN_PATTERNS_SUBAGENT_BUDGET_MS - 1; + expect(clampSubagentBudgets(CONFIG, deadline, now)).toBeNull(); + }); + + test('boundary: exactly the minimum budget → submit allowed', () => { + const deadline = now + CYCLE_DEADLINE_RESERVE_MS + MIN_PATTERNS_SUBAGENT_BUDGET_MS; + expect(clampSubagentBudgets(CONFIG, deadline, now)).toEqual({ + timeoutMs: MIN_PATTERNS_SUBAGENT_BUDGET_MS, + waitTimeoutMs: MIN_PATTERNS_SUBAGENT_BUDGET_MS, + }); + }); + + test('deadline already past → null, never a negative timeout', () => { + expect(clampSubagentBudgets(CONFIG, now - 1000, now)).toBeNull(); + }); +}); + +describe('claim stamps timeout_at (deadlineAtMs ground truth)', () => { + let engine: PGLiteEngine; + let queue: MinionQueue; + + beforeAll(async () => { + engine = new PGLiteEngine(); + await engine.connect({ database_url: '' }); // in-memory + await engine.initSchema(); + queue = new MinionQueue(engine); + }); + + afterAll(async () => { + await engine.disconnect(); + }); + + test('job with timeout_ms → claim sets timeout_at ≈ now + timeout_ms', async () => { + const before = Date.now(); + await queue.add('sync', {}, { timeout_ms: 600_000 }); + const claimed = await queue.claim('tok-dl-1', 30000, 'default', ['sync']); + const after = Date.now(); + expect(claimed).not.toBeNull(); + expect(claimed!.timeout_at).not.toBeNull(); + const at = claimed!.timeout_at!.getTime(); + expect(at).toBeGreaterThanOrEqual(before + 600_000 - 5_000); + expect(at).toBeLessThanOrEqual(after + 600_000 + 5_000); + }); + + test('job without timeout_ms and no handler default → timeout_at stays null', async () => { + // 'sync' is not in the long-handler default set, so no stamp either way. + await queue.add('sync', { which: 'no-timeout' }); + // Drain the possibly-remaining job from the prior test first. + let claimed = await queue.claim('tok-dl-2', 30000, 'default', ['sync']); + while (claimed && claimed.timeout_ms != null) { + claimed = await queue.claim('tok-dl-2', 30000, 'default', ['sync']); + } + expect(claimed).not.toBeNull(); + expect(claimed!.timeout_ms).toBeNull(); + expect(claimed!.timeout_at).toBeNull(); + }); +}); + +describe('deadline plumbing wiring (structural)', () => { + const workerSrc = readFileSync(new URL('../src/core/minions/worker.ts', import.meta.url), 'utf-8'); + const jobsSrc = readFileSync(new URL('../src/commands/jobs.ts', import.meta.url), 'utf-8'); + const cycleSrc = readFileSync(new URL('../src/core/cycle.ts', import.meta.url), 'utf-8'); + const patternsSrc = readFileSync(new URL('../src/core/cycle/patterns.ts', import.meta.url), 'utf-8'); + + test('worker exposes deadlineAtMs from the claim-time timeout_at stamp', () => { + expect(workerSrc).toContain('deadlineAtMs: job.timeout_at != null ? job.timeout_at.getTime() : null'); + }); + + test('worker arms its abort timer from timeout_at when present (one absolute deadline)', () => { + expect(workerSrc).toContain('job.timeout_at.getTime() - Date.now()'); + }); + + test('autopilot-cycle, global-maintenance AND phase-wrapper handlers thread deadlineAtMs into runCycle', () => { + const matches = jobsSrc.match(/deadlineAtMs: job\.deadlineAtMs/g) ?? []; + expect(matches.length).toBe(3); + }); + + test('runCycle forwards deadlineAtMs to the patterns phase', () => { + expect(cycleSrc).toContain('deadlineAtMs: opts.deadlineAtMs ?? null'); + }); + + test('patterns submits + waits with the CLAMPED budgets, not raw config', () => { + expect(patternsSrc).toContain('timeout_ms: budgets.timeoutMs'); + expect(patternsSrc).toContain('timeoutMs: budgets.waitTimeoutMs'); + expect(patternsSrc).not.toContain('timeout_ms: config.subagentTimeoutMs'); + expect(patternsSrc).not.toContain('timeoutMs: config.subagentWaitTimeoutMs'); + }); + + test('patterns cancels the child on wait timeout (child clock starts at ITS claim)', () => { + // A child that sat queued can outlive the parent deadline the wait was + // clamped to; the timeout path must strip it so it can't keep spending. + expect(patternsSrc).toContain('queue.cancelJob(job.id)'); + }); + + test('patterns skips honestly when the remaining budget is too small', () => { + expect(patternsSrc).toContain('insufficient_cycle_budget'); + // Budget gate sits AFTER the provider probe so a no-provider brain + // still reports no_provider (cheaper, more actionable reason). + const probeIdx = patternsSrc.indexOf("skipped('no_provider'"); + // lastIndexOf: the doc comment on MIN_PATTERNS_SUBAGENT_BUDGET_MS + // mentions the reason string too; the CALL SITE is the later hit. + const budgetIdx = patternsSrc.lastIndexOf('insufficient_cycle_budget'); + expect(probeIdx).toBeGreaterThan(0); + expect(budgetIdx).toBeGreaterThan(probeIdx); + }); +}); diff --git a/test/e2e/ingestion-roundtrip.test.ts b/test/e2e/ingestion-roundtrip.test.ts index 732b1cf11..a0fae218c 100644 --- a/test/e2e/ingestion-roundtrip.test.ts +++ b/test/e2e/ingestion-roundtrip.test.ts @@ -49,6 +49,7 @@ function makeFakeJobCtx(data: Record): MinionJobContext { data, attempts_made: 1, signal: new AbortController().signal, + deadlineAtMs: null, shutdownSignal: new AbortController().signal, updateProgress: async () => {}, updateTokens: async () => {}, diff --git a/test/e2e/subagent-crash-replay-multi-provider.test.ts b/test/e2e/subagent-crash-replay-multi-provider.test.ts index 140350400..75935ee05 100644 --- a/test/e2e/subagent-crash-replay-multi-provider.test.ts +++ b/test/e2e/subagent-crash-replay-multi-provider.test.ts @@ -279,6 +279,7 @@ async function makeCrashedCtx(jobId: number, prompt: string, modelId: string): P data: { prompt, model: modelId }, attempts_made: 1, // crashed once signal: abortCtrl.signal, + deadlineAtMs: null, shutdownSignal: shutdownCtrl.signal, updateProgress: async () => {}, updateTokens: async () => {}, diff --git a/test/e2e/subagent-gateway-path.test.ts b/test/e2e/subagent-gateway-path.test.ts index 3fa1cedf8..98fdfdb07 100644 --- a/test/e2e/subagent-gateway-path.test.ts +++ b/test/e2e/subagent-gateway-path.test.ts @@ -92,6 +92,7 @@ async function makeFakeJob(opts: FakeJobOpts): Promise<{ jobId: number; ctx: Min data: { prompt: opts.prompt, model: opts.model, allowed_tools: opts.allowed_tools }, attempts_made: 0, signal: abortCtrl.signal, + deadlineAtMs: null, shutdownSignal: shutdownCtrl.signal, updateProgress: async () => {}, updateTokens: async (t) => { tokenSink.push(t); }, diff --git a/test/e2e/subagent-gateway-resume-reconciliation.test.ts b/test/e2e/subagent-gateway-resume-reconciliation.test.ts index d7c479a3a..cb38e3042 100644 --- a/test/e2e/subagent-gateway-resume-reconciliation.test.ts +++ b/test/e2e/subagent-gateway-resume-reconciliation.test.ts @@ -68,7 +68,7 @@ async function makeJob(prompt: string, model: string): Promise<{ jobId: number; const jobId = rows[0].id; const ctx: MinionJobContext = { id: jobId, name: 'subagent', data: { prompt, model }, attempts_made: 1, - signal: new AbortController().signal, shutdownSignal: new AbortController().signal, + signal: new AbortController().signal, deadlineAtMs: null, shutdownSignal: new AbortController().signal, updateProgress: async () => {}, updateTokens: async () => {}, log: async () => {}, isActive: async () => true, readInbox: async () => [], }; diff --git a/test/handlers-embed-backfill.test.ts b/test/handlers-embed-backfill.test.ts index 3c7e4c772..f09ff67dd 100644 --- a/test/handlers-embed-backfill.test.ts +++ b/test/handlers-embed-backfill.test.ts @@ -48,6 +48,7 @@ function fakeJob(data: Record): MinionJobContext { data, attempts_made: 0, signal: controller.signal, + deadlineAtMs: null, shutdownSignal: controller.signal, updateProgress: async () => {}, updateTokens: async () => {}, diff --git a/test/ingestion/ingest-capture.test.ts b/test/ingestion/ingest-capture.test.ts index 3262ada39..7f4bdaf94 100644 --- a/test/ingestion/ingest-capture.test.ts +++ b/test/ingestion/ingest-capture.test.ts @@ -58,6 +58,7 @@ function makeJob(data: Record): MinionJobContext { data, attempts_made: 1, signal: new AbortController().signal, + deadlineAtMs: null, shutdownSignal: new AbortController().signal, updateProgress: async () => {}, updateTokens: async () => {}, diff --git a/test/minions-shell.test.ts b/test/minions-shell.test.ts index b4f5ec117..ff1f311f9 100644 --- a/test/minions-shell.test.ts +++ b/test/minions-shell.test.ts @@ -52,6 +52,7 @@ function makeCtx( data, attempts_made: 0, signal: opts.signal ?? new AbortController().signal, + deadlineAtMs: null, shutdownSignal: opts.shutdownSignal ?? new AbortController().signal, updateProgress: async () => {}, updateTokens: async () => {}, diff --git a/test/subagent-aggregator.test.ts b/test/subagent-aggregator.test.ts index 94a36b9e0..318fb12e3 100644 --- a/test/subagent-aggregator.test.ts +++ b/test/subagent-aggregator.test.ts @@ -40,6 +40,7 @@ function ctxWithInbox( data, attempts_made: 0, signal: new AbortController().signal, + deadlineAtMs: null, shutdownSignal: new AbortController().signal, async updateProgress(p: unknown) { progress.push(p); }, async updateTokens() {}, diff --git a/test/subagent-handler.test.ts b/test/subagent-handler.test.ts index 3b50a7122..da5a830d7 100644 --- a/test/subagent-handler.test.ts +++ b/test/subagent-handler.test.ts @@ -90,6 +90,7 @@ async function makeCtx(input: unknown): Promise { data: (input as Record) ?? {}, attempts_made: 0, signal: ac.signal, + deadlineAtMs: null, shutdownSignal: shutdown.signal, async updateProgress() {}, async updateTokens() {},