refactor(providers): share AbortCoordinator across both provider query loops#701
refactor(providers): share AbortCoordinator across both provider query loops#701griffinwork40 wants to merge 1 commit into
Conversation
…y loops
openai-compatible/query.ts hand-rolled the per-session abort slot that
anthropic-direct already encapsulates in AbortCoordinator, including six
copies of the compare-and-clear invariant:
if (this.abortController === controller) this.abortController = null;
That invariant is safety-critical — a leaked stale controller makes
compact()'s idle guard misread "turn in flight" as idle and run
summarization mid-turn — and maintaining it by hand in two providers is
exactly the drift risk shared/tool-loop-cap.ts exists to prevent.
Move AbortCoordinator to providers/shared/ (it had zero imports and no
SDK types — it was already provider-neutral) and wire openai-compatible
through it. Four fields (abortController, pendingAbortReason,
closeResolve, closedPromise) collapse into one `abort` collaborator;
clear() becomes the only write path to null.
Net -26 lines of logic in query.ts, and the six duplicated invariant
sites become six calls. File length is ~flat because the freed space
went to documenting the contract at each site.
Also adds shared/abort-coordinator.test.ts (14 cases): scope lifecycle,
stale-scope compare-and-clear protection, pending-abort drain, and the
close-promise race the main loop depends on. The class was previously
covered only indirectly via concurrent-session-isolation.test.ts.
BEHAVIOURAL DELTA (one, intentional, convergent):
compactHistory's beginAbort now goes through AbortCoordinator.begin(),
which drains a pending abort reason. An interrupt() parked between turns
therefore pre-aborts the auto-compaction firing at that boundary and
consumes the reason, instead of letting compaction run and deferring the
abort to the next turn. This matches
anthropic-direct/query/compact-handler.ts:148. Documented inline at the
call site.
Part of #360 (god-class decomposition); follows #407, which extracted
client/model-params/retry from the same file.
Verification: pnpm lint clean, full suite 699 files / 13082 passed,
audit:sdk:check + audit:env:check + scan:env:check all green, pnpm build
succeeds.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
CI note: the
|
main @ e71fbce (this PR's base) |
this PR | |
|---|---|---|
| windows job conclusion | failure | failure |
| failing test files | 85 | 86 |
| failing tests | 537 | 537 |
Same failing-test count, and the failures are the same pre-existing Windows-compat set — memory-loader.test.ts, skill-bridge.test.ts, bash.test.ts, tool-injector.test.ts, launchd.test.ts, etc. The two providers/ files that appear (anthropic-direct/grant-manager.test.ts, openai-compatible/auth.test.ts) also fail on main's Windows job and are untouched here.
Root cause of the sampled memory-loader.test.ts failure, for whoever picks up the compat work: the suite sets process.env['HOME'] to a tmp dir, but getAfkHome() falls back to join(homedir(), '.afk') (src/paths.ts:56) and Node's os.homedir() reads USERPROFILE on Windows — so the fixture directory is never consulted.
The file this PR adds passes on Windows: ✓ src/agent/providers/shared/abort-coordinator.test.ts (14 tests).
Worth flagging for future triage: a run-level conclusion of success on main does not mean the Windows job passed — continue-on-error masks it at the run level. The job-level conclusion has to be read directly.
What
openai-compatible/query.tshand-rolled the per-session abort slot thatanthropic-directalready encapsulates inAbortCoordinator— including six copies of the compare-and-clear invariant:This moves
AbortCoordinatortoproviders/shared/and wiresopenai-compatiblethrough it.Why
That invariant is safety-critical: a leaked stale controller makes
compact()'s idle guard misread "turn in flight" as idle and run summarization mid-turn. Maintaining it by hand in two providers is exactly the drift riskshared/tool-loop-cap.tsexists to prevent — andanthropic-direct/query.tsalready has zero rawthis.abortControllerreferences because it solved this.AbortCoordinatorwas already provider-neutral: zero imports, no SDK types. Only its docstring said "Anthropic." Copying it intoopenai-compatible/would have created the duplication this PR removes, so it moves toshared/instead.abortControllerrefs in openai query.tsclear()write path)Four fields (
abortController,pendingAbortReason,closeResolve,closedPromise) collapse into oneabortcollaborator. Net −26 lines of logic inquery.ts; total file length is ~flat because the freed space went into documenting the contract at each site.compactHistory'sbeginAbortnow goes throughAbortCoordinator.begin(), which drains a pending abort reason.An
interrupt()parked between turns therefore pre-aborts the auto-compaction firing at that boundary and consumes the reason, instead of letting compaction run and deferring the abort to the next turn.This matches
anthropic-direct/query/compact-handler.ts:148and is arguably the more correct reading (ESC should stop compaction too), but it is a change. Narrow repro window: pending interrupt and auto-compact enabled and the threshold crossing at that exact boundary. Documented inline at the call site.Preserving the old behaviour would have required adding a non-draining
beginvariant to the shared class for one caller — working against the anti-drift goal of the PR. Flagging it explicitly rather than burying it.Tests
Adds
shared/abort-coordinator.test.ts(14 cases) — the class previously had no dedicated test, covered only indirectly viaconcurrent-session-isolation.test.ts. Now that it's load-bearing for two providers that gap was worth closing:Verification
pnpm lintclean (tsc --noEmit, strict)audit:sdk:check,audit:env:check,scan:env:checkall greenpnpm buildsucceedsScope
Part of #360 (god-class decomposition); follows #407, which extracted
client/model-params/retryfrom this same file.Deliberately not in this PR:
SessionStatebag — moves no logic on its own; only earns its place as the precondition for extracting the turn engine._runTurnInner209 LOC +runIteration119) — the largest remaining mass and where ~4/5 of recent commits land, but a big diff on the hottest-churn path deserves its own review.compactHistoryextraction — looks like a seam, isn't: its logic already lives incompact.tsbehind injected callbacks, so the remaining 58 lines are a thin adapter. Moving it would be file-hop, not decomposition.