From bafd091e612b9156a69662e95bf55179b217c455 Mon Sep 17 00:00:00 2001 From: Yash Date: Mon, 14 Sep 2026 22:48:32 +0000 Subject: [PATCH] fix(harness): preserve continuation preparation deadline --- .changeset/assistant-continuation-timeout.md | 5 + packages/harness/docs/assistant-history.md | 5 + .../core/assistant-lifecycle-timeout.test.ts | 277 ++++++++++++++++++ .../harness/src/core/assistant-lifecycle.ts | 6 +- 4 files changed, 292 insertions(+), 1 deletion(-) create mode 100644 .changeset/assistant-continuation-timeout.md create mode 100644 packages/harness/src/core/assistant-lifecycle-timeout.test.ts diff --git a/.changeset/assistant-continuation-timeout.md b/.changeset/assistant-continuation-timeout.md new file mode 100644 index 000000000..3fb685107 --- /dev/null +++ b/.changeset/assistant-continuation-timeout.md @@ -0,0 +1,5 @@ +--- +"@sapiom/harness": patch +--- + +Give Continue its full preparation budget while retaining shorter inspection deadlines and immediate End cancellation. diff --git a/packages/harness/docs/assistant-history.md b/packages/harness/docs/assistant-history.md index 7a788190d..7e32e068f 100644 --- a/packages/harness/docs/assistant-history.md +++ b/packages/harness/docs/assistant-history.md @@ -158,3 +158,8 @@ event. Runtime activation remains an explicit capability from its existing owner Inspection and Resume responses recheck the complete opaque history scope after asynchronous work. A changed project, account, harness or native binding invalidates the result even when the workspace path and lifecycle revision are unchanged. + +Continue allows up to 45 seconds total for cold startup and frozen preparation. +Selected inspection retains its separate 15-second limit. The caller's remaining +deadline and End can cancel either operation sooner; provisional cleanup remains +bound to the exact runtime that the operation started. diff --git a/packages/harness/src/core/assistant-lifecycle-timeout.test.ts b/packages/harness/src/core/assistant-lifecycle-timeout.test.ts new file mode 100644 index 000000000..c5b0d24e5 --- /dev/null +++ b/packages/harness/src/core/assistant-lifecycle-timeout.test.ts @@ -0,0 +1,277 @@ +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import type { AssistantLifecycle } from "../shared/assistant-session.js"; +import { AssistantLifecycleCoordinator } from "./assistant-lifecycle.js"; +import type { AssistantSessionStore } from "./assistant-session-store.js"; +import type { HostedOpenCode } from "./opencode-host.js"; + +// Lifecycle imports node:timers directly; route it through the fake clock too. +vi.mock("node:timers", async (importOriginal) => ({ + ...(await importOriginal()), + setTimeout: (callback: () => void, ms?: number) => + globalThis.setTimeout(callback, ms), + clearTimeout: (timer: ReturnType) => + globalThis.clearTimeout(timer), +})); + +const id = "studio-child"; +const delay = (ms: number) => new Promise((done) => setTimeout(done, ms)); +const fixture = ( + timeouts: { + inspectionTimeoutMs?: number; + continuationTimeoutMs?: number; + } = {}, +) => { + let current: HostedOpenCode | null = null; + let saved: AssistantLifecycle | null = null; + const controllers = new WeakMap(); + const createHost = (): HostedOpenCode => { + const controller = new AbortController(); + const hosted: HostedOpenCode = { + harnessSessionId: id, + cwd: "/workspace", + stateRoot: "/private/native", + contextAuthorityScope: "a".repeat(64), + model: { providerID: "sapiom", modelID: "fixture" }, + signal: controller.signal, + isCurrent: () => current === hosted && !controller.signal.aborted, + server: { + pid: 123, + exited: new Promise(() => {}), + close: async () => {}, + fetch: vi.fn(), + fetchJson: vi.fn(), + }, + }; + controllers.set(hosted, controller); + return hosted; + }; + const publish = () => (current = createHost()); + const retireExact = vi.fn(async (hosted: HostedOpenCode) => { + controllers.get(hosted)!.abort(); + if (current === hosted) current = null; + }); + const retireWithResult = vi.fn(async () => { + if (!current) return { state: "absent" as const }; + await retireExact(current); + return { state: "confirmed" as const }; + }); + const ensure = vi.fn(async () => current ?? publish()); + const associate = vi.fn(), + observe = vi.fn(); + const store: Pick = { + lifecycle: vi.fn(async () => saved), + transition: vi.fn( + async (sessionId, revision, next) => { + saved = { + version: 1, + harnessSessionId: sessionId, + revision: revision + 1, + ...next, + updatedAt: Date.now(), + }; + return saved; + }, + ), + }; + const coordinator = new AssistantLifecycleCoordinator({ + ...timeouts, + store, + associations: { ensure: associate }, + host: { + current: () => current, + ensure, + assertCurrent: async (hosted) => { + if (!hosted.isCurrent()) throw new Error("stale runtime"); + }, + retireExact, + retireWithResult, + observe, + beginShutdown: () => { + void retireWithResult(); + }, + }, + }); + return { + coordinator, + ensure, + retireExact, + retireWithResult, + store, + associate, + observe, + createHost, + publish, + current: () => current, + }; +}; + +beforeEach(() => vi.useFakeTimers()); +afterEach(() => { + vi.clearAllTimers(); + vi.useRealTimers(); +}); + +it("allows cold startup and frozen input preparation to share the 45 second Continue budget", async () => { + const f = fixture(); + f.ensure.mockImplementationOnce(async () => { + await delay(20_000); + return f.publish(); + }); + const prepare = vi.fn(async () => { + await delay(20_000); + return "prepared"; + }); + const failed = vi.fn(); + const pending = f.coordinator.prepareContinuation(id, 0, prepare); + void pending.catch(failed); + await vi.advanceTimersByTimeAsync(15_000); + expect(failed).not.toHaveBeenCalled(); + expect(prepare).not.toHaveBeenCalled(); + expect(f.retireWithResult).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(5_000); + const hosted = f.current()!; + expect(prepare).toHaveBeenCalledOnce(); + await vi.advanceTimersByTimeAsync(20_000); + expect(await pending).toBe("prepared"); + expect(f.current()).toBe(hosted); + expect(hosted.signal.aborted).toBe(false); + expect(f.retireExact).not.toHaveBeenCalled(); + expect(f.associate).not.toHaveBeenCalled(); + expect(f.observe).not.toHaveBeenCalled(); + expect(f.store.transition).not.toHaveBeenCalled(); + expect(vi.getTimerCount()).toBe(0); +}); + +it("expires Continue at 45 seconds total across startup and a hung native preparation", async () => { + const f = fixture(); + f.ensure.mockImplementationOnce(async () => { + await delay(20_000); + return f.publish(); + }); + const prepare = vi.fn(() => new Promise(() => {})); + const pending = f.coordinator.prepareContinuation(id, 0, prepare); + const rejected = expect(pending).rejects.toMatchObject({ + failure: { code: "transport_unavailable" }, + }); + await vi.advanceTimersByTimeAsync(20_000); + const hosted = f.current()!; + expect(prepare).toHaveBeenCalledOnce(); + await vi.advanceTimersByTimeAsync(24_999); + expect(hosted.signal.aborted).toBe(false); + await vi.advanceTimersByTimeAsync(1); + await rejected; + expect(f.retireExact).toHaveBeenCalledExactlyOnceWith(hosted); + expect(f.current()).toBeNull(); + expect(f.associate).not.toHaveBeenCalled(); + expect(vi.getTimerCount()).toBe(0); +}); + +it("fences pending Continue startup at 45 seconds and retires a late exact host without touching its replacement", async () => { + const f = fixture(), + late = f.createHost(); + let release!: (hosted: HostedOpenCode) => void; + f.ensure.mockImplementationOnce( + () => + new Promise((done) => { + release = done; + }), + ); + const prepare = vi.fn(); + const pending = f.coordinator.prepareContinuation(id, 0, prepare); + const rejected = expect(pending).rejects.toMatchObject({ + failure: { code: "transport_unavailable" }, + }); + await vi.advanceTimersByTimeAsync(44_999); + expect(f.retireWithResult).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1); + await rejected; + expect(f.retireWithResult).toHaveBeenCalledExactlyOnceWith(id, 750); + expect(prepare).not.toHaveBeenCalled(); + await f.coordinator.prepareContinuation(id, 0, async () => "retried"); + const replacement = f.current()!; + release(late); + await vi.advanceTimersByTimeAsync(0); + expect(f.retireExact).toHaveBeenCalledExactlyOnceWith(late); + expect(late.signal.aborted).toBe(true); + expect(replacement.signal.aborted).toBe(false); + expect(f.current()).toBe(replacement); + expect(vi.getTimerCount()).toBe(0); +}); + +it.each([ + { mode: "inspect" as const, deadline: 15_000, options: {} }, + { + mode: "inspect" as const, + deadline: 25, + options: { inspectionTimeoutMs: 25, continuationTimeoutMs: 125 }, + }, + { + mode: "prepareContinuation" as const, + deadline: 125, + options: { inspectionTimeoutMs: 25, continuationTimeoutMs: 125 }, + }, +])( + "uses the independent $mode deadline of $deadline ms", + async ({ mode, deadline, options }) => { + const f = fixture(options); + const pending = f.coordinator[mode]( + id, + 0, + () => new Promise(() => {}), + ); + const rejected = expect(pending).rejects.toMatchObject({ + failure: { code: "transport_unavailable" }, + }); + await vi.advanceTimersByTimeAsync(deadline - 1); + const hosted = f.current()!; + expect(hosted.signal.aborted).toBe(false); + await vi.advanceTimersByTimeAsync(1); + await rejected; + expect(f.retireExact).toHaveBeenCalledExactlyOnceWith(hosted); + expect(vi.getTimerCount()).toBe(0); + }, +); + +it("End aborts prolonged Continue preparation immediately without advancing its deadline", async () => { + const f = fixture(); + const pending = f.coordinator.prepareContinuation( + id, + 0, + () => new Promise(() => {}), + ); + const rejected = expect(pending).rejects.toMatchObject({ + failure: { code: "lifecycle_changed" }, + }); + await vi.advanceTimersByTimeAsync(20_000); + const hosted = f.current()!, + now = Date.now(); + const ending = f.coordinator.beginEnd(id); + expect(hosted.signal.aborted).toBe(true); + expect(f.retireWithResult).toHaveBeenCalledExactlyOnceWith(id); + await rejected; + await ending.native; + await ending.persistence; + expect(Date.now()).toBe(now); + expect((await f.coordinator.finishEnd(ending.fence)).lifecycle).toBe("ended"); + expect(f.associate).not.toHaveBeenCalled(); + expect(vi.getTimerCount()).toBe(0); +}); + +it("preserves a shorter caller deadline and cleans up its exact provisional runtime", async () => { + const f = fixture(), + caller = new AbortController(); + const pending = f.coordinator.prepareContinuation( + id, + 0, + () => new Promise(() => {}), + caller.signal, + ); + const reason = new Error("outer Continue deadline"); + const rejected = expect(pending).rejects.toBe(reason); + await vi.advanceTimersByTimeAsync(10_000); + const hosted = f.current()!; + caller.abort(reason); + await rejected; + expect(f.retireExact).toHaveBeenCalledExactlyOnceWith(hosted); + expect(vi.getTimerCount()).toBe(0); +}); diff --git a/packages/harness/src/core/assistant-lifecycle.ts b/packages/harness/src/core/assistant-lifecycle.ts index ee9e48a5b..2e4321b93 100644 --- a/packages/harness/src/core/assistant-lifecycle.ts +++ b/packages/harness/src/core/assistant-lifecycle.ts @@ -41,6 +41,8 @@ export interface AssistantResumePreparation { interface Options { inspectionTimeoutMs?: number; resumeTimeoutMs?: number; + /** Total startup and frozen-context/seed preparation budget; defaults to 45 seconds. */ + continuationTimeoutMs?: number; /** Continue keeps an allocated child closed to ordinary Attach until seed preparation commits. */ canAttach?: (id: string) => Promise; store: Pick & @@ -574,7 +576,9 @@ export class AssistantLifecycleCoordinator { : controller.signal; const timer = setTimeout( () => controller.abort(inspectionFailure("transport_unavailable")), - this.options.inspectionTimeoutMs ?? 15_000, + retain + ? (this.options.continuationTimeoutMs ?? 45_000) + : (this.options.inspectionTimeoutMs ?? 15_000), ); const check = () => { signal.throwIfAborted();