Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
89026d1
feat(agent): engage fallback model chain and reset-aware cooldown on 429
lsm Jul 27, 2026
a005b59
feat(space): surface rate_limited/usage_limited task status with auto…
lsm Jul 27, 2026
755f340
fix(agent,space): address review — cross-restart resume, error bounda…
lsm Jul 27, 2026
bc71f95
Merge branch 'dev' into space/wire-global-model-fallback-chain-on-rat…
lsm Jul 30, 2026
e05f3c8
fix(migrations): make M163 tolerate space_tasks without a status CHECK
lsm Jul 31, 2026
65e56e1
fix(agent,space): address 2nd-round review — lifecycle interplay, vis…
lsm Jul 31, 2026
b2e6c61
fix(agent,space): address 3rd-round review — infinite-loop, state, co…
lsm Jul 31, 2026
66a16e1
fix(agent,space): route 429 into recovery + stop limited tasks on spa…
lsm Jul 31, 2026
286dcc6
fix(space): merge parallel limited sessions into the persisted restri…
lsm Jul 31, 2026
d035cc3
fix(agent,space): per-turn episode reset, split cancel semantics, spa…
lsm Jul 31, 2026
de5c525
fix(space): break a paused session out of cooldown on manual Resume (…
lsm Jul 31, 2026
568183b
Merge branch 'dev' into space/wire-global-model-fallback-chain-on-rat…
lsm Aug 1, 2026
d7e6200
fix(agent,space): address post-merge review — injection gate, resume …
lsm Aug 1, 2026
0ed75c1
fix(web): add rate/usage-limited → blocked transition labels
lsm Aug 1, 2026
11cc724
fix(agent): gate manual retryNow resume on query start + bound startu…
lsm Aug 1, 2026
e75e5ee
fix(agent,space): in-memory-only liveness check + restore cooldown be…
lsm Aug 1, 2026
12c06d0
fix(space): tear down session on manual rate/usage-limited → blocked …
lsm Aug 1, 2026
2609df4
Merge branch 'dev' into space/wire-global-model-fallback-chain-on-rat…
lsm Aug 2, 2026
9fc05ca
Merge branch 'dev' (artifact-shapes #2313) into fallback-chain branch
lsm Aug 2, 2026
f2135c2
fix(agent): close fractional-zoned reset parse hole + keep task pause…
lsm Aug 2, 2026
b6e1ea8
fix(agent,web): harden rate-limit recovery concurrency + episode life…
lsm Aug 2, 2026
c886ffa
fix(agent): propagate episode generation into every recovery side eff…
lsm Aug 2, 2026
7372173
fix(agent): guard recovery enqueue at the lifecycle commit point + st…
lsm Aug 2, 2026
9be5fd1
fix(agent,space): gate rehydration injection on task status + recheck…
lsm Aug 2, 2026
c1c751e
fix(agent,space): supersede recovery on new input + transient limited…
lsm Aug 2, 2026
2b612a7
fix(agent,space): route resettable quota 429s to recovery + manual-re…
lsm Aug 2, 2026
353c505
refactor(agent,space): greptile cleanups — CJK dedup, typo, fractiona…
lsm Aug 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 113 additions & 3 deletions packages/daemon/src/lib/agent/agent-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ import type {
SystemPromptConfig,
McpServerConfig,
Provider,
FallbackModelEntry,
} from '@hyperneo/shared';
import type {
ChatMessage,
Expand Down Expand Up @@ -247,6 +248,8 @@ import { MessageRecoveryHandler } from './message-recovery-handler';
import { RewindHandler, type RewindHandlerContext, type RewindPoint } from './rewind-handler';
import { SessionConfigHandler, type SessionConfigHandlerContext } from './session-config-handler';
import { RateLimitWatchdog } from './rate-limit-watchdog';
import { resolveFallbackChain } from './fallback-recovery';
import { getProviderRegistry } from '../providers/factory.js';

/**
* AgentSession - Pure facade that delegates to specialized handlers
Expand Down Expand Up @@ -427,9 +430,65 @@ export class AgentSession
// Initialize SessionConfigHandler (handlers take AgentSession context directly)
this.sessionConfigHandler = new SessionConfigHandler(this);

// Initialize RateLimitWatchdog — detects 429 exhaustion and schedules auto-retry
this.rateLimitWatchdog = new RateLimitWatchdog(session.id, this.stateManager);
this.rateLimitWatchdog.setRetryCallback(async (lastUserMessage) => {
// Initialize RateLimitWatchdog — detects 429/usage-limit exhaustion and
// drives two-phase recovery: (A) immediate fallback-model switch via the
// configured fallback chain, then (B) a cooldown at a parsed reset time or
// on a backoff ladder. Deps are injected here so the watchdog stays free of
// session/DB/provider coupling.
this.rateLimitWatchdog = new RateLimitWatchdog(session.id, this.stateManager, {
Comment thread
lsm marked this conversation as resolved.
getCurrentModel: () => ({
provider: (this.session.config.provider as string | undefined) ?? 'anthropic',
model: this.session.config.model ?? 'sonnet',
}),
Comment thread
lsm marked this conversation as resolved.
resolveChain: () => {
const gs = this.settingsManager.getGlobalSettings();
const { provider, model } = this.session.config;
return resolveFallbackChain(
(provider as string | undefined) ?? 'anthropic',
model ?? 'sonnet',
gs.modelFallbackMap,
gs.fallbackModels
Comment thread
lsm marked this conversation as resolved.
);
},
isEntryAvailable: async (entry) => {
try {
const reg = getProviderRegistry();
const p = reg.detectProviderForModel(entry.model, entry.provider);
if (!p) return false;
// `isAvailable()` is the authoritative runtime gate — it covers
// env-var / gh CLI / hosts.yml credentials as well as HyperNeo-managed
// auth.json. Do NOT additionally require `getAuthStatus().isAuthenticated`:
// some providers (e.g. anthropic-copilot) intentionally report
// `isAuthenticated: false` for externally-provided credentials, so that
// check would wrongly make a usable fallback appear unavailable.
return await Promise.resolve(p.isAvailable());
} catch {
return false;
}
},
switchAndRetry: (lastUserMessage, entry) =>
this.switchAndRetryForFallback(lastUserMessage, entry),
notifyPause: (payload) => {
this.internalEventBus.publish('session.rate_limit_pause', {
sessionId: this.session.id,
kind: payload.kind,
resetAt: payload.resetAt,
reason: payload.reason,
});
},
notifyResume: () => {
this.internalEventBus.publish('session.rate_limit_resume', {
sessionId: this.session.id,
});
},
});
this.rateLimitWatchdog.setRetryCallback(async (lastUserMessage, switchTo) => {
if (switchTo) {
// A cooldown that was scheduled after a fallback switch re-switches
// before re-enqueuing (rare). Goes through the same timing-safe path.
await this.switchAndRetryForFallback(lastUserMessage, switchTo);
return;
}
await this.executeRateLimitAutoRetry(lastUserMessage);
});

Expand Down Expand Up @@ -772,6 +831,57 @@ export class AgentSession
// Rate Limit Auto-Retry
// ============================================================================

/**
* Switch to a fallback model and re-enqueue the last user message (Phase A of
* rate-limit recovery). Timing-critical: this MUST run after the failed
* query's `finally` block completes, so we `await this.queryPromise` first
* (it resolves only once query-runner has torn the query down — queryObject
* nulled, env restored, setIdle called). By then the query is inactive, so
* `handleModelSwitch` takes its config-only branch (model-switch-handler) and
* `executeRateLimitAutoRetry` starts a fresh query with the new model.
*
* Returns false if the switch itself failed so the watchdog marks the entry
* tried and advances to the next chain entry.
*/
private async switchAndRetryForFallback(
lastUserMessage: { uuid: string; content: string | MessageContent[] } | null,
entry: FallbackModelEntry
): Promise<boolean> {
if (!lastUserMessage) {
this.logger.warn('Fallback switch skipped: no last user message available.');
await this.stateManager.setIdle();
return false;
}
try {
// (1) Wait for the failed query's cleanup to finish before mutating config.
if (this.queryPromise) {
try {
await this.queryPromise;
} catch {
// The failed query already rejected; its finally has still run.
}
}

// (2) Switch model. The query is inactive now → config-only branch, no restart.
const result = await this.handleModelSwitch(entry.model, entry.provider);
Comment thread
lsm marked this conversation as resolved.
if (!result.success) {
this.logger.warn(
`Fallback switch to ${entry.provider}/${entry.model} failed: ${result.error}. ` +
`Will try the next chain entry.`
);
return false;
}

// (3) Re-enqueue with the new model and start a fresh query.
await this.executeRateLimitAutoRetry(lastUserMessage);
return true;
Comment thread
lsm marked this conversation as resolved.
Outdated
} catch (err) {
this.logger.error('Fallback switch-and-retry failed:', err);
await this.stateManager.setIdle();
return false;
}
}

/**
* Execute auto-retry after rate limit cooldown.
* Re-enqueues the last user message and starts a new query.
Expand Down
Loading