Wire global model fallback chain on rate/usage limits + reset-aware cooldown - #2271
Conversation
Sessions hitting rate/usage caps (e.g. third-party relay 429 with a reset timestamp) never recovered: the watchdog retried the same model at a fixed 10min x3 then gave up, and GlobalSettings.fallbackModels/modelFallbackMap had no runtime consumers. - New pure fallback-recovery module: chain resolution (map override vs global), next-entry selection (skip tried / same-model / unavailable), format-agnostic reset-timestamp extraction (ISO-8601, YYYY-MM-DD HH:mm:ss incl. the Chinese relay shape, epoch s/ms), and a backoff ladder (10m->4h, cap 8h) with jitter. - RateLimitWatchdog drives two-phase recovery: (A) immediate fallback-model switch via injected deps (free, tracked per-episode), then (B) a cooldown at the parsed reset time or on the backoff ladder. Reset-known waits don't count toward maxAutoRetries. - AgentSession wires the watchdog to settings (chain), the provider registry (availability), model-switch (switch+retry after the failed query's finally), and the internal event bus (pause/resume). - Add session.rate_limit_pause / session.rate_limit_resume events.
…-resume With the fallback chain wired (previous commit), a 429 no longer fails a Space worker task — the error broadcast is skipped so the session recovers or waits. This commit adds the visible status: when a worker session pauses on a cap with no fallback left, mark its task rate_limited / usage_limited with a resume-at restriction, and restore it to in_progress when the limit lifts. - Migration 163 widens space_tasks.status CHECK (rate_limited, usage_limited) and adds a nullable restrictions column; test-DB helper kept in parity. - SpaceTaskStatus + SpaceTask.restrictions (TaskRestriction) in shared types; repo reads/writes the JSON blob. - TaskAgentManager subscribes to session.rate_limit_pause/resume and maps the session to its parent task, setting/clearing the paused status. - Web status maps (labels, badges, transitions) cover the two new statuses.
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
lsm
left a comment
There was a problem hiding this comment.
🤖 Review by glm-5.1 (NeoKai)
Model: glm-5.1 | Client: NeoKai | Provider: GLM
Recommendation: REQUEST_CHANGES
Reviewed from scratch: full diff, all 17 changed files, the integration points (query-runner.ts, model-switch-handler.ts, processing-state-manager.ts), and the surrounding space-runtime tick/rehydrate paths. Ran the new/changed unit suites (53 + 70, all green) and bun run check (lint/typecheck/knip/parity/session-guards/test-quality — all clean). The coder's verification claims hold.
What's solid. The pure recovery module (fallback-recovery.ts) is well-factored and correctly handles the Chinese relay shape (2026-07-22 17:55:10 via LOCAL_DATETIME_RE, the [1308] code correctly ignored), the backoff ladder, and the freeWait semantics. The concurrency core is sound: switchAndRetryForFallback correctly await this.queryPromise so the failed query's finally (null queryObject, env restore, setIdle) completes before handleModelSwitch runs — I traced no double-query / dedup / late-finally race, and cross-provider switching works (config-only branch swaps provider + clears sdkSessionId). Migration 163 follows the established M98/M162 table-rebuild pattern (FK-off, column copy, idempotent guard) and is safe. The onMarkApiSuccess → reset() → notifyResume resume path is correctly wired.
The findings below are real and actionable but none are crash/incorrect-output bugs — they concern reliability of the feature's headline guarantee and test coverage of its central invariants.
P2-1 — Auto-resume does not survive a daemon restart (the motivating use case)
The cooldown is an in-memory unref'd setTimeout (rate-limit-watchdog.ts:295). The persisted restrictions.resetAt blob (migration 163 + repo round-trip) is written on pause but never read on startup. The space-runtime tick loop only drives open/in_progress/blocked tasks (space-runtime.ts:4066), so a usage_limited/rate_limited task is never re-driven, and rehydrating a session constructs a fresh watchdog with no timer/retryCount.
Consequence: for a 5-hour or weekly cap — the exact scenario this PR exists to fix — a daemon restart during the wait leaves the task paused indefinitely with no auto-resume and no error. This is the same "persisted data with no runtime consumer" anti-pattern this PR removes for fallbackModels; the new resetAt repeats it. The manual Resume button (TaskStatusActions.tsx: rate_limited->in_progress) is an escape hatch, so it is recoverable, not a hard-stuck — but the "auto-resumes after reset" criterion (Part C / VERIFICATION) is unmet across restarts, and a paused task with a future resetAt that nobody arms is misleading in the UI.
Fix options: on daemon/workflow start, scan paused tasks — those with resetAt in the past → restore in_progress + clear restrictions; those still future → re-arm a runtime-level scheduled restore (or re-arm the watchdog cooldown on rehydrate). At minimum, explicitly document that auto-resume requires daemon uptime. The persisted resetAt should either be consumed or not persisted as a resume promise.
P2-2 — fireImmediateFallback has no error boundary; a rejection sticks fallbackPending = true
rate-limit-watchdog.ts:321-339 is void-fired from scheduleRetry:239 with no try/catch. switchAndRetryForFallback has a catch-all, but its own catch calls this.stateManager.setIdle() which can throw (DB write); the recursive await this.scheduleRetry(...) at :337 can also reject. If anything escapes, the promise rejects unhandled AND this.fallbackPending is never reset (:326 is skipped), so getState() reports 'fallback-pending' forever and retryNow() (:365) hard-returns false — a stuck-visible state with no recovery short of reset(). Wrap the body in try/catch: on error, log, clear fallbackPending, mark the entry tried, and fall through to a cooldown.
P2-3 — Untested load-bearing invariants
Three behaviors central to this feature have no test, so they will silently regress:
- Parsed-reset waits bypass
maxAutoRetries. This is the design guarantee ("reset-known waits don't count toward the budget"). The only exhaustion test (rate-limit-watchdog.test.ts:159) uses'429'(no timestamp) and assertsfalse; nothing asserts that withretryCount === maxAutoRetries+ a parseable ISO reset,scheduleRetryreturnstrueandretryCountstays put. - Late resume does not resurrect a cancelled/done task. Explicitly in the review checklist. The source guard (
restoreTaskFromRateLimit:610) is correct, but the only resume tests coverusage_limited→in_progress(:99) and thein_progressno-op (:141); nothing publishessession.rate_limit_resumeagainst acancelled/done/archivedtask. The terminal-override test (:129) only coversdoneon the pause side. resetAtbuffer arithmetic on thenotifyPausepayload is asserted nowhere (watchdog emitsdecision.retryAtMs= reset + 30s,:285); a regression dropping the buffer passes every test.
P3 — optional polish (not blocking on their own)
rate-limit-watchdog.ts:283firesnotifyPausebeforesetRateLimitCooldown(:289); reorder so the processing-state flip precedes the bus event and avoids a briefidlewindow whereonIdleCallbackruns.rate-limit-watchdog.ts:237comment says scheduleRetry "must return true synchronously" — it isawaited atquery-runner.ts:1314; rephrase to "must resolve to true".fallback-recovery.ts:301-312classifyLimitKindkeywords are broad ('exceeded','limit reached') and any parsed timestamp →usage_limit, so a transient "rate limit exceeded, retry in 60s" is labelled a daily/weekly cap. Label-only impact (both resume identically), but worth narrowing.- A manual status transition out of
rate_limited/usage_limitedviasetTaskStatus(not the resume path) leaves a stalerestrictionsblob; clear it on exit. No frontend consumer yet, latent. VALID_SPACE_TASK_TRANSITIONSomitsrate_limited/usage_limited → archived(every other non-terminal status can archive). Likely intentional; confirm.
Happy to re-review once P2-1 through P2-3 are addressed.
…ry, invariant tests P2-1 auto-resume across daemon restart: the in-memory watchdog cooldown doesn't survive a restart, so add a SpaceRuntime tick sweep (recoverRateLimitedTasks) that restores rate/usage-limited tasks whose persisted restrictions.resetAt has passed to in_progress (clearing restrictions), driven off the persisted blob so the normal rehydration restarts the worker. Tasks with a future resetAt stay paused. New repo helper listRateLimitedBySpace. P2-2 error boundary: wrap fireImmediateFallback in try/catch so a rejecting switchAndRetry or recursive scheduleRetry can't leave fallbackPending stuck true (which froze getState/retryNow). On error: clear fallbackPending, mark the entry tried, fall through to a cooldown. P2-3 invariant tests: parsed-reset wait bypasses maxAutoRetries (true + retryCount unchanged at the budget); 30s buffer pinned on notifyPause resetAt; late resume does not resurrect cancelled/done/archived; pause-side terminal guard covers done/blocked/cancelled/archived; cross-restart sweep test. P3 polish: setRateLimitCooldown before notifyPause; fix 'return true synchronously' comment; narrow classifyLimitKind keywords (drop 'exceeded'/ 'limit reached' that mislabelled transient rate limits); auto-clear stale restrictions on manual transition out of the paused statuses; add rate_limited/usage_limited → archived transitions + labels.
lsm
left a comment
There was a problem hiding this comment.
🤖 Review by glm-5.1 (NeoKai)
Model: glm-5.1 | Client: NeoKai | Provider: GLM
Recommendation: APPROVE
Round 2 (fresh, commit 755f340). All three P2 findings and the P3 polish are correctly addressed; I re-verified each on its merits rather than trusting the summary.
P2-1 (cross-restart auto-resume) — fixed. SpaceRuntime.recoverRateLimitedTasks() runs in the tick loop off the persisted restrictions.resetAt: past-reset → restore in_progress + clear restrictions (worker re-driven by the normal in_progress rehydrate/recoverStalledRuns path, which has separate coverage); future-reset → left paused. listRateLimitedBySpace + the repo's auto-clear of stale restrictions on any exit transition back it up. The only residual — a backoff-path rate_limited task carries a synthetic resetAt ≈ now+1h rather than the exact ladder delay — is safe: usage caps (the long-window case) always carry an accurate parsed reset, and a transient rate limit retrying slightly early is harmless. Not worth blocking.
P2-2 (error boundary) — fixed. fireImmediateFallback now wraps switchAndRetry in try/catch with a finally that always clears fallbackPending, and guards the recursive scheduleRetry with a best-effort cooldown fallback. No path leaves fallbackPending stuck or getState()/retryNow() frozen.
P2-3 (invariant tests) — fixed, and the assertions are meaningful: parsed-reset wait bypasses maxAutoRetries (returns true with retryCount unchanged at the budget); the 30s RESET_BUFFER_MS is pinned on the notifyPause payload; late resume does not resurrect cancelled/done/archived and confirms the restrictions auto-clear; pause-side terminal guard broadened to done/blocked/cancelled/archived.
P3 — all applied: setRateLimitCooldown before notifyPause; "must resolve to true" comment; classifyLimitKind narrowed (dropped the over-broad exceeded/limit reached/达到; the Chinese relay still classifies correctly via 上限/小时 and the parsed-reset path); rate_limited/usage_limited → archived transitions + labels.
Verification: bun run check clean (lint/typecheck/knip/parity/session-guards/test-quality); changed suites green (watchdog+fallback 55, listener+tick-loop+repo 125); all 4 prior threads resolved; PR open and mergeable. No new issues or regressions introduced by the fix commit. Ship it.
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: bc71f95761
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
The migration-marker runner tests seed a minimal space_tasks sentinel (no
status CHECK) to verify migrations are idempotent on bare schemas. runMigration163
threw 'space_tasks status CHECK constraint not found' there, failing
4-space-migrations-b in CI.
Align with M103's guard: only run the CHECK-widening rebuild when the live DDL
actually contains 'status IN ('; otherwise skip the rebuild and just add the
nullable restrictions column via ALTER (idempotent). Real pipeline-created
space_tasks tables have the CHECK, so the rebuild still runs there; bare
sentinel schemas get the column without the throw.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: e05f3c8620
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
…ibility, in-flight cancellation P1 paused-task/workflow lifecycle interplay: - processRunTick now early-returns for rate_limited/usage_limited canonical tasks so the persisted in_progress execution isn't classified as crashed/respawned while the cap cooldown holds (the recoverRateLimitedTasks sweep owns the resume). - isWorkflowRecoveryTransition now treats rate_limited/usage_limited → in_progress as a recovery transition, so manual Resume routes through recoverWorkflowBackedTask (resets the run + re-drives the worker) instead of only updating the row. - shouldStopWorkflowForStatus now includes the paused source statuses, so manual Cancel stops the workflow + tears down the cooldown session (the watchdog is destroyed with it) rather than leaving the run to retry on a cancelled task. P1 in-flight fallback cancellation: fireImmediateFallback now captures an episode generation; cancel()/reset() bumps it. A switch suspended on query teardown checks the generation after resuming and skips the chain advance + re-enqueue when superseded, so a user's new message isn't raced by a stale fallback. P2 provider availability: isEntryAvailable trusts isAvailable() alone (the authoritative runtime gate covering env/gh-CLI/hosts.yml creds) and no longer additionally requires getAuthStatus().isAuthenticated — the latter is UI-only for some providers (anthropic-copilot) and wrongly skipped usable fallbacks. P2 backoff retryAt: notifyPause now always carries decision.retryAtMs (incl. backoff-ladder steps), so the persisted resetAt the cross-restart sweep trusts is the honest next-retry time, not an arbitrary 1h. P2 task visibility: route rate_limited/usage_limited to the Action tab (isActionRequired + ACTION_GROUPS) so paused tasks stay visible with their manual Resume/Cancel actions instead of disappearing from every Tasks tab. Tests: isWorkflowRecoveryTransition (shared), cancel-during-fallback (watchdog), task-filters partition incl. the two new statuses (web).
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 65e56e1b50
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
…ncurrency fixes P1 fallback infinite loops: - onMarkApiSuccess only resets the watchdog episode on a success-result frame, not every SDK frame (init/error frames fired it too). Resetting on an init frame cleared the tried-set mid-recovery, so a fallback that re-429'd could re-select the same entry (A/B loop). - Canonical model-ID dedup: the tried-set and chain selection now key on the resolved model ID (resolveModelId dep) so a session configured with an alias and a canonical fallback entry for the same model are recognized as the same, not retried forever. - extractResetTimestamp scans every candidate per strategy (matchAll) so a stale past timestamp before the future quota reset no longer aborts the search and falls through to backoff. P2 propagate retry failure: executeRateLimitAutoRetry returns success; switchAndRetryForFallback reports false when startQueryAndEnqueue fails so the watchdog advances the chain instead of leaving the message idle. P1 preserve task lifecycle: markTaskRateLimited only pauses in_progress tasks — a rate-limited post-approval executor (review/approved) keeps its lifecycle state. P1 concurrency on resume: getRunningTaskCount counts rate_limited/usage_limited tasks, so a paused task holds its slot and the cross-restart resume can't exceed maxConcurrentTasks. P1 multi-session resume: track limited sub-sessions per task; restore only when the last limited session resumes, so an early resume doesn't hide a remaining cooldown (and its persisted reset deadline). P1 goal active-task: isActiveTaskStatus treats rate_limited/usage_limited as active so a paused goal task isn't cleared from activeTaskId, preventing a second concurrent task for the same goal. P2 legacy provider: switchAndRetryForFallback backfills the inferred Anthropic provider for persisted sessions missing one, so ModelSwitchHandler doesn't reject every configured fallback. P2 task-list sync: pause/resume publish space.task.updated so connected web clients see the new status + restriction without a refresh. Tests: multi-timestamp scan, alias-dedup, init-frame non-reset, multi-session resume, non-in_progress pause no-op, updated agent-session onMarkApiSuccess.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: b2e6c610ce
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
…ce/run cancel
P1 429 never reached the fallback chain (critical): handleApiValidationError's
generic 4\d{2} regex matched 429 and returned true BEFORE the rate-limit branch,
so the common 429 / API Error: 429 shape was rendered as a terminal validation
error and never engaged the fallback chain or reset-aware cooldown. Added
looksLikeRateLimit429 (leading 429 / API Error: 429 / JSON-envelope inner 429)
and bail handleApiValidationError for it so 429 falls through to the rate-limit
branch. 402/quota/billing and other 4xx still handled as validation.
P1 stop limited tasks on space/workflow cancel: stopActiveWork() and
cancelWorkflowRun() now include rate_limited/usage_limited in their cancellation
filters, so stopping a space or cancelling a run during a cooldown tears down the
live session + its watchdog timer instead of letting the timer fire afterward and
re-enqueue work on a cancelled task.
P2 reset paused execution without crash accounting: recoverRateLimitedTasks now
resets the paused task's in_progress node execution to pending directly (not via
the crash-retry path), so a planned cooldown recovery doesn't consume a
MAX_TASK_AGENT_CRASH_RETRY and reduce the budget for genuine spawn failures.
Tests: looksLikeRateLimit429 (429/API-Error/JSON shapes, 402/401/400 negatives,
no mid-string false positives).
…ction P1: the "only pause in_progress" guard discarded a second session's pause event when the task was already rate/usage-limited, so the persisted restriction kept only the first session's resetAt. On a daemon restart with two sessions paused at different deadlines, recoverRateLimitedTasks restored after the earlier one and respawned the task while the slower session should still be limited. markTaskRateLimited now allows merging into an already-limited task: it takes the LATER resetAt (so the cross-restart sweep waits for the slowest session) and the STRONGER kind (usage_limit over rate_limit), and skips the write when nothing changed. Terminal/decision statuses are still never overwritten. Test: two-session pause merges to the later resetAt + usage_limited kind.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 286dcc63aa
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
…wn gates P1 regression (0999): executeRateLimitAutoRetry -> startQueryAndEnqueue -> cancel() bumped the episode generation, so an in-flight fallback self-aborted and the failed message went idle. Split the watchdog: clearPendingCooldown() (timer + flag only, no generation bump, episode preserved) for the recovery re-enqueue path; cancel() (generation bump + fresh episode) reserved for genuine reset/interrupt. startQueryAndEnqueue now uses clearPendingCooldown. P2 new turn fresh episode (0991): track episodeMessageUuid; a scheduleRetry with a different UUID (genuine new user turn) resets triedKeys/chain/retryCount so the new request gets the full fallback chain and budget. Recovery re-enqueues the same UUID, so the episode (which fallbacks were tried) is preserved. P2 canonicalize for modelFallbackMap lookup (1003): resolveChain now canonicalizes the current model before the map lookup (UI saves override keys from ModelInfo.id), so an alias-configured session matches its model-specific override instead of silently using the global list. resolveChain is now async. P2 clear dead session binding (0976): recoverRateLimitedTasks clears the stale agentSessionId when resetting the paused execution to pending, so processRunTick no longer detects it as a dead in_progress/pending session and runs it through the crash-retry path (consuming a MAX_TASK_AGENT_CRASH_RETRY). P2 cancel paused dependents (0985): doCancelDependentsCascade now cancels rate_limited/usage_limited dependents, so a cancelled prerequisite doesn't leave a paused dependent to be later auto-restored to in_progress. P1 gate out-of-band spawns (1006): validateTaskAllowsSpawn now rejects rate_limited/usage_limited tasks, so external-event / peer-handoff activations (activateTargetSessionsForMessage) can't bypass the tick loop's paused-task guard and spawn during the cooldown. Tests: per-turn fresh-episode reset; updated cooldown-progression tests to mirror the real same-UUID clearPendingCooldown flow.
…0981) Manual Resume (rate/usage-limited → in_progress) routes through recoverWorkflowBackedTask, which reset the execution + reattached MCP tools but left the live session in rate_limit_cooldown — so the task showed in_progress while sitting idle until the watchdog timer fired at resetAt. recoverWorkflowBackedTask now calls taskAgentManager.resumeRateLimitedSubSession for each live session: if it's in rate_limit_cooldown, fire retryNowAfterRateLimit to cancel the timer and re-run the turn immediately. No-op for sessions not in cooldown; guarded so partial (mock) TAMs don't break.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: de5c525f6d
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
lsm
left a comment
There was a problem hiding this comment.
🤖 Review by glm-5.1 (zai)
Model: glm-5.1 | Client: NeoKai | Provider: zai
Recommendation: REQUEST_CHANGES — not for logic, which I re-verified as sound, but for a merge-conflict with dev that must be resolved before merge. All 15 bot findings are already fixed.
A. Codex bot findings (reviews 4824157940 + 4824868845) — 15/15 ALREADY RESOLVED on de5c525f6
I independently verified every one of the two Codex review batches against the current head (7 parallel verification passes, each with code + commit evidence). All 15 are resolved, most by b2e6c610c ("3rd-round review") and 65e56e1b5 ("2nd-round review"), each backed by unit tests. Codex review 4824868845 was run against commit_id e05f3c862 (the grandparent of the fixes), which is why its findings landed after the fixes — they are stale, not new regressions.
| # | ID | Sev | Area | Verdict | Fixed by |
|---|---|---|---|---|---|
| 1 | r3687033409 | P1 | tick-ordering respawn race | ✅ resolved | 65e56e1b5 (spawn guard, space-runtime.ts:6907) |
| 2 | r3687033415 | P2 | getAuthStatus wrongly gating availability |
✅ resolved | 65e56e1b5 |
| 3 | r3687033422 | P2 | backoff retryAt not persisted |
✅ resolved | 65e56e1b5 |
| 4 | r3687033425 | P1 | in-flight fallback not cancelled | ✅ resolved | 65e56e1b5 (episode-generation token) |
| 5 | r3687033431 | P1 | workflow lifecycle on Resume/Cancel | ✅ resolved | 65e56e1b5 + de5c525f6 |
| 6 | r3687033435 | P2 | task invisible in tabs/sidebar | ✅ resolved | 65e56e1b5 (isActionRequired) |
| 7 | r3687033438 | P2 | stop at first invalid timestamp | ✅ already correct | matchAll iterates all candidates (fallback-recovery.ts:200) |
| 8 | r3687033442 | P2 | failed retry reported as success | ✅ resolved | b2e6c610c |
| 9 | r3687033446 | P1 | preserve pre-limit task lifecycle | ✅ resolved | b2e6c610c |
| 10 | r3687568769 | P1 | restore on first of N sub-sessions | ✅ resolved | b2e6c610c + 286dcc63a (limitedSessionsByTask) |
| 11 | r3687568773 | P1 | goal isActiveTaskStatus omits limited |
✅ resolved | b2e6c610c |
| 12 | r3687568774 | P1 | init-frame clears fallback episode | ✅ resolved | b2e6c610c (gated to isSDKResultSuccess) |
| 13 | r3687568776 | P1 | alias vs canonical dedup loop | ✅ resolved | b2e6c610c (canonical resolveModelId both sides) |
| 14 | r3687568778 | P2 | legacy session provider → failed switch | ✅ resolved | b2e6c610c (backfill provider pre-switch) |
| 15 | r3687568780 | P2 | pause/resume no space.task.updated |
✅ resolved | b2e6c610c + 286dcc63a (emitTaskUpdatedEvent) |
Logic verdict: APPROVE. No outstanding logic/behavior issues. (Minor, non-blocking note: the per-task limitedSessionsByTask tracker is in-memory; on a restart between two sub-session resumes it's lost — but the persisted restrictions.resetAt is now the max of all paused sessions, so recoverRateLimitedTasks still cannot restore early. No correctness hole.)
B. ⚠️ Real blocker: merge conflict with dev (needs rebase)
GitHub reports mergeable: CONFLICTING. The branch is 12 commits behind dev with conflicts in 3 files:
packages/daemon/src/lib/space/runtime/space-runtime.ts← overlaps ourrecoverRateLimitedTasks/ spawn-guard / tick codepackages/daemon/src/lib/space/runtime/space-runtime-service.tspackages/daemon/src/storage/schema/migrations.ts← our M163
The conflict is driven mainly by:
- #2289
refactor(space): make task lifecycle the only external-event delivery gate— a space-runtime refactor most likely to require ourrecoverRateLimitedTasks/spawn-guard logic to be re-fit to the new delivery-gate model. - #2282 / #2284 / #2287 normalized message-replacements refactor — touches the SDK-message path where our
onMarkApiSuccess/isSDKResultSuccessgating lives.
After resolving, please re-run the full daemon shards (not just touched files — this touches the tick loop + migrations, the same surface that bit us in an earlier round):
./scripts/test-daemon.sh 1-core
./scripts/test-daemon.sh 5-space-runtime-a # tick loop
./scripts/test-daemon.sh 4-space-migrations-b # M163
then push and let CI re-run. No --delete-branch (per convention).
Summary: The rate/usage-limit + fallback-chain feature is solid — every external bot finding is already addressed with tests. The only thing standing between this PR and merge is the dev rebase. Once conflicts in the 3 files above are resolved and the daemon shards are green, this is good to merge.
…e-usage-limits Resolved 3 conflicts: - space-runtime-service.ts: kept dev's holdSpaceDeliveries() delivery-gate (#2289) AND my expanded stopActiveWork filter (in_progress/open/rate_limited/ usage_limited). - space-runtime.ts: took dev's isValidSpaceTaskTransition(status,'cancelled') form in cancelWorkflowRun — it subsumes my explicit paused-status list because rate_limited/usage_limited → cancelled is already in the transition table. - migrations.ts: migration-number collision — dev shipped M163 (SDK message-UUID normalization), so renumbered this branch's space_tasks status-CHECK migration to M164 (runMigration164). Both run; fresh-DB verified to apply both. Also extended dev's new web TASK_STATUS_CONFIG map (Record<SpaceTaskStatus,...>) with rate_limited/usage_limited entries. Verified: bun run check clean; shards green — 1-core (3744), 4-space-storage (1843), 4-space-migrations-a/b (227/221), 5-space-runtime-a/b (392/844); web 88.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 568183b925
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
…ordering, regex, cascades P1 gate message injection during cooldown (4540): injectMessageIntoSession now always defers incoming messages (even immediate delivery from external events / peer handoffs) when the session is in rate_limit_cooldown, so the pause can't be bypassed via the live cooldown session. P1 preserve live sessions during cooldown recovery (6853): recoverRateLimitedTasks skips a task entirely when its worker session is still alive (live watchdog owns the resume) — previously it reset the execution and could spawn a second agent for the same slot. P2 notify resume only after a successful retry (4543): the cooldown timer fires via fireCooldownRetry, which awaits the retry callback and only notifies resume once the query actually started; on failure it reschedules a short cooldown so the consumed message isn't orphaned. Callback type now returns Promise<boolean>. P2 exclude zoned timestamps from the local-datetime strategy (4546): LOCAL_DATETIME_RE gains a negative lookahead so a zoned timestamp the ISO pass rejected isn't reparsed as a daemon-local reset that delays recovery for hours. P2 migration 164 no-FK comma (6852): addRateUsageStatusAndRestrictions now inserts the leading comma when injecting restrictions TEXT before the closing paren of a no-FOREIGN-KEY schema (was producing invalid CREATE TABLE SQL). P2 honor explicitly empty fallback overrides (6851): resolveFallbackChain now treats key PRESENCE (not length) as the override selector, so an explicitly empty modelFallbackMap entry disables fallback for that model instead of inheriting the global list. P2 block limited dependents on prerequisite failure (6848): doBlockCascade scans rate_limited/usage_limited dependents too, and the transition table allows rate_limited/usage_limited → blocked. P2 goal-automation review dedup (4549): findActiveCompletedTaskReviewTask treats rate_limited/usage_limited as active so a paused review task isn't dedup-skipped into a duplicate review episode. Tests: zoned-timestamp non-reparse + local-with-trailing-text; empty-override disables fallback.
TaskStatusActions' "has a label for every valid transition" test failed because the previous commit added rate_limited/usage_limited → blocked to the transition map (for the block-cascade fix) without the matching TRANSITION_LABELS entries, which the test requires one label per valid transition. Adds Block labels.
…cycle (codex round) Address codex's batch of 7 findings on head f2135c2 (3 P1, 4 P2): P1 — generation-guard completeness: - scheduleRetry now captures the episode generation BEFORE its first await (resolveModelId/chain/availability) and re-checks it before firing any side effect (fallback switch + cooldown). Previously this.generation was read AFTER the awaits, so a cancel()/reset() during resolution already bumped it and the captured value matched → fireImmediateFallback switched providers + replayed the stopped message. (fallback-recovery capture-before-await) - fireCooldownRetry captures the generation at entry and aborts if a cancel/ reset superseded the episode during the awaited retry callback — otherwise the callback re-enqueued the stale turn after the user stopped/replaced it. P1 — banner Cancel must not resume: - cancel(notifyResume=true) gains a flag; cancelRateLimitRetry (the cooldown banner's Cancel) passes false. Resuming there restored the task to in_progress and the ensuing idle transition was misread as successful node completion, advancing the workflow past a failed turn. The banner path now leaves the task paused/blocked. P2 — episode/pause lifecycle: - New-UUID episode reset also clears startupRetries (a replacement turn no longer inherits the prior turn's failed-startup count and exhausts prematurely). - Startup-retry exhaustion sets a startupExhausted flag so retryNow (manual Resume) still works without a pending timer; otherwise the in-memory session was skipped by the cross-restart sweep and the task was stuck until restart. P2 — false fallback re-entry: - fireImmediateFallback's re-entry now handles a false scheduleRetry return (budget exhausted after a fallback became available) by scheduling a deferrive cooldown, so the consumed turn isn't left idle with no driver. P2 — overview action count: - SpaceOverview uses the shared isActionRequired predicate (incl. rate/usage- limited) for its Action/Review stat, matching the Action tab. Tests: 6 new watchdog tests (superseded-during-resolution, superseded-mid- callback, cancel(false), post-exhaustion Retry Now, per-episode startup budget reset, false-reentry deferrive cooldown) + the prior exhaustion test.
lsm
left a comment
There was a problem hiding this comment.
🤖 Review by GLM-5.1 (GLM)
Model: GLM-5.1 | Client: NeoKai | Provider: GLM (Zhipu)
Recommendation: APPROVE (round 8, head b6e1ea88a)
Independently verified all 7 codex fixes (3 P1 + 4 P2) plus the two f2135c222 P2s from scratch against the current worktree — not the diff alone. All correct; tests green.
P1 concurrency hardening (rate-limit-watchdog.ts):
scheduleRetrycapturesentryGenerationsynchronously before the first await (:249), re-checks it before both side effects (fallback switch :314, cooldown schedule :359), and passes the entry generation — notthis.generation— intofireImmediateFallback(:332). Closes the mid-resolution cancel race. ✓fireCooldownRetrycaptures generation at entry (:436) and aborts (no notifyResume, no re-arm) if superseded during the awaited retry callback (:450). Post-timer async window closed; no double-resume / dangling timer. ✓cancel(notifyResume=true)flag; banner Cancel passesfalseso it no longer falsely restores the task toin_progress(which the completion listener could misread as node completion). ✓
Exhaustion lifecycle (f2135c222 + b6e1ea88a): terminal startup-retry exhaustion no longer calls notifyResume (it was a false-in_progress orphan) and instead sets startupExhausted, so retryNow admits a manual Resume even without a pending timer. I traced every recovery path — manual Resume, the per-tick recoverRateLimitedTasks sweep (driven by persisted restrictions.resetAt, skips live sessions via isSessionInMemory), and daemon restart. Not stuck. I also checked the banner-Cancel recovery edge case: although cancel(false) severs the in-process retryNow/resumeRateLimitedSubSession paths (state→idle, startupExhausted cleared), the primary affordance — manual Resume via recoverWorkflowBackedTask — restores the task to in_progress in its transaction (:5566, gated only by isValidSpaceTaskTransition(rate_limited→in_progress)) independent of cooldown state, so the task is fully recoverable in-process; resumeRateLimitedSubSession is only an optimization to break a live cooldown immediately. ✓
Mechanical correctness:
- Fractional-zoned regex lookahead (
f2135c222) now rejects.<digit>after seconds, so a stale fractional-zoned timestamp the ISO pass dropped is no longer re-accepted as a future local datetime. Bare local shapes (incl. the Chinese relay message) unaffected. ✓ (42/0) - Migration renumbered M164→M167; chain 160–167 contiguous, marker-gated, idempotent, no-FK comma fix preserved. ✓ (2335/0). Minor: no dedicated M167 unit test — coverage gap, not a defect.
SpaceOverviewAction/Review count now uses the sharedisActionRequiredpredicate. ✓ (22/0)
Merge state: merge-base == origin/dev (49d371961 — fully caught up incl. dev's #2313 artifact-shapes), 0 conflict markers, MERGEABLE / mergeStateStatus CLEAN, 0 unresolved threads / 62, required CI checks pass. Ready for human merge.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: b6e1ea88a2
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
lsm
left a comment
There was a problem hiding this comment.
🤖 Review by GLM-5.1 (GLM)
Model: GLM-5.1 | Client: NeoKai | Provider: GLM (Zhipu)
Recommendation: REQUEST_CHANGES — supersedes my round-8 APPROVE on b6e1ea88a.
Codex posted 4 new findings (all P1) after my round-8 approval. I verified each first-hand against the worktree — all four are real, and they share one root cause.
Root cause: the episode-generation guard pattern (sound, and correctly applied at the watchdog's top-level boundaries this round) is incomplete. It re-checks generation only at scheduleRetry (:314/:359), fireCooldownRetry (:450), and fireImmediateFallback (:537/:559). But the side effects that actually mutate the session live inside the awaited callbacks/sub-methods, where the captured generation is never re-checked:
-
P1 —
fireCooldownRetry:443 callback replays the stale message. The callback isexecuteRateLimitAutoRetry, which callsstartQueryAndEnqueue()(agent-session.ts:961) with no generation check. A cancel/interrupt during the callback'ssetIdleawait (:958) bumps the generation, butstartQueryAndEnqueueproceeds — the stale message is enqueued and the query started before the :450 check runs (which then only suppresses resume/re-arm). The interrupt is defeated; the stale turn runs. [discussion_r3700228396] -
P1 —
fireImmediateFallback:545 switch commits before the :559 guard.switchAndRetryForFallbackawaits the failed query's teardown (:879), then performshandleModelSwitch(:899) andexecuteRateLimitAutoRetry(:912) inside the await — none check generation. A cancel during the teardown await bumps generation, but the provider switch + stale replay already commit before :559 (which only blocks chain advancement). [discussion_r3700228397] -
P1 —
fireImmediateFallback:572 re-entry treats a cancelled turn as fresh. After the :559 guard passes,await resolveModelId(:572) opens a window.cancel()bumps generation and leaveslastUserMessagepopulated (it clearsepisodeMessageUuidbut notlastUserMessage). After the await,triedKeys.add(:573) mutates state andscheduleRetry(:579) captures the newly-incremented generation as its baseline — so the stale message is treated as a fresh valid episode and another fallback is selected or a cooldown armed. [discussion_r3700228399] -
P1 —
scheduleCooldown:387 publishes a stale pause + arms a stale timer. AfterscheduleRetry's :359 guard,scheduleCooldownawaitssetRateLimitCooldown(:387). A cancel during that await bumps generation, butcancel()sees a null timer and an unpublished pause (notifyPauseat :398 hasn't run) — nothing to undo. After the await,notifyPause(:398) publishes a stale pause (overriding the cancel's resume) and a stale cooldown timer is armed (:404) that later fires intofireCooldownRetryand replays the message. [discussion_r3700228402]
Severity rationale: same class as the P1-A fix accepted this round (scheduleRetry entry-generation capture). The PR's explicit goal is interrupt-safe recovery, and these four windows each let an explicit cancel/interrupt be defeated and the stale turn replayed — immediately (1, 2) or deferred via an armed timer / re-entered recovery (3, 4). Rate-limit recovery is precisely when users interrupt, so the windows are practically reachable.
Fix direction (one coherent change at 4 sites): propagate the captured episodeGeneration into each callback/sub-method and re-check it immediately before every side effect — before startQueryAndEnqueue in executeRateLimitAutoRetry; after the teardown await and before handleModelSwitch/executeRateLimitAutoRetry in switchAndRetryForFallback; after resolveModelId and before triedKeys.add/scheduleRetry re-entry in fireImmediateFallback; and after the setRateLimitCooldown await and before notifyPause/timer-arm in scheduleCooldown. For startQueryAndEnqueue's genuine-new-user-input path the check must stay opt-in via the recovery call sites, not the shared method — consistent with the existing clearPendingCooldown vs cancel split at agent-session.ts:807-814.
The non-concurrency fixes (exhaustion lifecycle, fractional-zoned regex, M167 migration, overview filter) remain correct; only the interrupt-safety of the recovery path needs completing.
…ect (codex round) Address codex/reviewer's 4 P1 findings (REQUEST_CHANGES on b6e1ea8). One root cause: the episode-generation guard lived only at the watchdog's top-level boundaries, but the session-mutating side effects (startQueryAndEnqueue, handleModelSwitch, notifyPause+timer-arm) live INSIDE the awaited callbacks / sub-methods, where generation was never re-checked — so a cancel during those internal awaits bumped generation but the side effect still committed, replaying the stale turn / pausing a dead episode. The captured episodeGeneration is now propagated into each side-effect site and re-checked immediately before it commits: - executeRateLimitAutoRetry: re-check isSuperseded(gen) right before startQueryAndEnqueue (after the setIdle await). Opt-in at this recovery call site, NOT in the shared startQueryAndEnqueue, so genuine new user input is unaffected (consistent with the clearPendingCooldown vs cancel split). - switchAndRetryForFallback: re-check after the teardown await (before handleModelSwitch) and after handleModelSwitch (before the re-enqueue). - scheduleCooldown: re-check after the setRateLimitCooldown await, before notifyPause + the timer arm (cancel during that write saw a null timer + unpublished pause, so the post-cancel pause+timer previously committed). - cancel(): also clears lastUserMessage so the fireImmediateFallback re-entry path (gated on `if (this.lastUserMessage)`) can't re-arm recovery for the stopped message via a scheduleRetry that captures the bumped generation as a fresh baseline. Wiring: RateLimitRetryCallback + the switchAndRetry dep carry episodeGeneration; the watchdog exposes isSuperseded(gen) for the agent-session guards; scheduleRetry / fireCooldownRetry / fireImmediateFallback pass their captured generation throughout (including the deferrive-cooldown re-entry paths). Tests (4 new, watchdog level): superseded during the cooldown state write → no pause/timer; cancel() clears lastUserMessage; episodeGeneration is threaded into switchAndRetry and into the retry callback (the contract the agent-session isSuperseded gates rely on).
lsm
left a comment
There was a problem hiding this comment.
🤖 Review by GLM-5.1 (GLM)
Model: GLM-5.1 | Client: NeoKai | Provider: GLM (Zhipu)
Recommendation: APPROVE (round 9, head c886ffab2)
Verified the 4 generation-threading P1 fixes first-hand against the worktree. All correct — each guard sits exactly after its await and immediately before its side effect, with no await in between (the gap that defeated the prior boundary-only guards). Threading confirmed: RateLimitRetryCallback/switchAndRetry/scheduleCooldown now carry episodeGeneration, and the captured value (not this.generation) is propagated at every call site — fireCooldownRetry:460, scheduleRetry:368, fireImmediateFallback:562 — so the guards actually fire.
executeRateLimitAutoRetry(Finding 1) —setIdleawait (:979) →isSuperseded(episodeGeneration)check (:986-992) →startQueryAndEnqueue(:995). Opt-in viaepisodeGeneration !== undefined, so the shared enqueue path (genuine new input) is untouched. ✓switchAndRetryForFallback(Finding 2) — guard after the teardownawait queryPromise(:893, beforehandleModelSwitch) AND afterhandleModelSwitch(:916, before re-enqueue). An abort here returns false and the post-switch guard at :576 catches it. ✓cancel()clearslastUserMessage(Finding 3) — verified SAFE: I traced every recovery path.retryNowafter cancel is unreachable (cancel nulls the timer + clearsstartupExhausted, so its own gate short-circuits); space-task manual Resume goes throughrecoverWorkflowBackedTask's DB transaction (restores task + node executions, re-drives via the tick using the task prompt — independent of the watchdog'slastUserMessage); the restart sweep isrestrictions.resetAt-driven (in-memorylastUserMessageis lost on restart anyway); non-space sessions keep the message in SDK history (only auto-replay is forfeited, the correct semantic for an explicit cancel); exhaustion doesn't call cancel, so post-exhaustion Retry Now still works. ✓scheduleCooldown(Finding 4) —setRateLimitCooldownawait (:393) →episodeGeneration !== this.generationre-check (:403-408) →notifyPause(:415) + timer arm (:421). A cancel during that write (null timer, unpublished pause) no longer commits either. ✓
One residual examined and cleared (not a finding): fireImmediateFallback:590 can add a stale triedKeys entry if a cancel lands during the resolveModelId await (:589) after the :576 guard passed. This is benign — that path is only reached when the switch genuinely failed (a Fix-2 abort returns at :576 before :583), so the entry is a just-failed model key (skipping it later is harmless), and triedKeys is cleared on the next new-UUID episode (:273) and on reset (:752). The replay itself is already blocked by Fix 3 (:594 if(this.lastUserMessage) is false). No action needed.
Prior fixes intact (scheduleRetry :251/:316/:361, fireCooldownRetry :453/:467, fireImmediateFallback :554/:576, cancel flag, startupExhausted lifecycle, exhaustion no-resume). Tests: watchdog 34/34 (4 new), agent-session 123/123 per the handoff.
Merge state: merge-base == origin/dev (49d371961), 0 conflict markers, MERGEABLE/mergeStateStatus CLEAN, 0 unresolved threads / 66, CI green (0 failures; CLI skips on PR branches as expected). Ready for human merge.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: c886ffab21
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
…artup-retry rearm (codex round) Address codex's round-4 P1 findings on head c886ffa (the generation guard lived above startQueryAndEnqueue / above the startup-retry state write, leaving the side-effect commit ungated during the internal awaits): 1. QueryLifecycleManager.startQueryAndEnqueue now takes an optional episodeGeneration and re-checks ctx.isRateLimitEpisodeSuperseded(gen) after ensureQueryStarted's awaits, BEFORE setQueued/enqueueWithId — so a cancel/ reset during query startup (clearModelsCache / startStreamingQuery) can't let a recovery re-enqueue commit the stale message into the replacement query. This is the terminal fix: enqueueWithId is the irreducible commit point, so no further 'one level deeper' window remains. Opt-in: only recovery (executeRateLimitAutoRetry) passes episodeGeneration; genuine new user input (undefined) is unaffected. Wired via a new ctx method AgentSession.isRateLimitEpisodeSuperseded → rateLimitWatchdog.isSuperseded. 2. fireCooldownRetry's startup-retry branch now rechecks entryGeneration after the setRateLimitCooldown await, before arming the short timer — a replacement message / Interrupt during that write (no timer for cancel to clear) no longer arms a stale timer that retries the old message into the replacement query. Tests: existing startQueryAndEnqueue delegation tests updated for the new episodeGeneration arg; watchdog suite 34/34, agent-session suite green. The round's two P2s (banner-Cancel in-process recovery path; parallel-session restriction recompute on partial resume) are deferred to Space task #812 — both are task-lifecycle edge cases (bounded refactors) tracked separately to keep this PR's blast radius contained, and both are strictly safer than prior behavior.
lsm
left a comment
There was a problem hiding this comment.
🤖 Review by GLM-5.1 (GLM)
Model: GLM-5.1 | Client: NeoKai | Provider: GLM (Zhipu)
Recommendation: APPROVE (round 10, head 7372173e6) — the generation-guard concurrency story now reaches the irreducible commit point.
Verified codex's 2 P1 fixes first-hand:
Fix 1 — guard at the lifecycle commit point (QueryLifecycleManager.startQueryAndEnqueue :655-706). await ensureQueryStarted() (:662) → isRateLimitEpisodeSuperseded(episodeGeneration) re-check (:668-677, returns/aborts) → all three commit paths (setQueued blocked :679, setQueued :689, enqueueWithId :693) are AFTER the guard with no await between. Confirmed: enqueueWithId is the irreducible bottom (the message-queue commit). Defense-in-depth with the round-9 :988 guard; episodeGeneration threaded correctly — executeRateLimitAutoRetry:999 → startQueryAndEnqueue:818 → lifecycle :670. ctx wiring correct: new QueryLifecycleManager(this) (:402) → AgentSession.isRateLimitEpisodeSuperseded (:1603) → rateLimitWatchdog.isSuperseded. Opt-in confirmed: the genuine-input caller event-subscription-setup.ts:123 omits the arg → undefined → unchecked. ✓
- The
:755/:759setQueued+enqueueWithId is inhandleQueuedMessageFailure(a delivery-timeout retry, gated onMessageQueueTimeoutError:720) — a different mechanism, not governed by the rate-limit episode generation. Out of scope for this PR's interrupt-safety goal; not a finding.
Fix 2 — startup-retry rearm guard (fireCooldownRetry :511-531). setRateLimitCooldown await (:511) → entryGeneration !== this.generation re-check (:523, returns) → timer arm (:531), no await between. Mirrors the scheduleCooldown guard from round 9; closes the sibling window I didn't verify last round. ✓
Two P2s deferred — acceptable per independent assessment, but recoverWorkflowBackedTask (I traced this in round 8 — the task is restored to in_progress by the DB tx independent of cooldown state); (b) parallel-session restriction recompute is a refinement of a feature this PR introduces (strictly safer, no regression). However, #812 is an unrelated, already-merged "short IDs in task cards" ticket, and an open-issue search finds nothing covering either P2 — so they are currently untracked. Please file a real tracking issue (or correct the number) so these aren't lost. I'm fine leaving them out of this PR.
Prior fixes intact; tests green per handoff (watchdog 34/34, agent-session suite). merge-base == origin/dev, 0 conflict markers, 0 unresolved threads.
Note: CI on 7372173e6 was still running at review time (3 jobs pending, 0 failures). I'll confirm green before the merge handoff.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 7372173e66
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
… fallback re-entry (codex round 5) Address codex's round-5 review on head 7372173: P1 — preserve cooldown when lazily rehydrating sessions: injectMessageIntoSession now also defers when the PARENT TASK is rate/usage-limited, not only when the volatile session state is rate_limit_cooldown. After a daemon restart, rehydration flips the persisted rate_limit_cooldown session state to idle, so the session-state check alone let an injected external-event/peer-handoff message resume work before restrictions.resetAt. The task row still carries the paused status until the cross-restart sweep restores it, so gate on it too. (The complementary recoverRateLimitedTasks re-arm for live rehydrated-idle sessions — so a correctly-deferred message replays once resetAt passes — is scoped in #812; current behavior is self-correcting: a session resumed during the window re-429s and re-triggers the fallback/cooldown.) P2 — recheck the episode after canonicalizing a failed fallback: fireImmediateFallback's re-entry rechecks episodeGeneration after the resolveModelId await, before triedKeys.add / scheduleRetry re-entry. A superseding turn during that await can repopulate lastUserMessage and reset the shared episode state; without the check this stale continuation would poison the new episode's tried-set and re-arm recovery for the replacement message. Test: aborts the fallback re-entry if superseded during the canonical resolve. The round's other P2 (revalidate dependencies before resuming a limited task) is deferred to #812 — an edge-case interaction between SpaceTaskManager.updateTask dependency validation and the rate_limited status.
lsm
left a comment
There was a problem hiding this comment.
🤖 Review by glm-5.1 (NeoKai)
Model: glm-5.1 | Client: NeoKai | Provider: Zhipu (GLM)
Recommendation: APPROVE (own-PR, so posted as COMMENT — this is an explicit approval verdict).
Round-11 verification (first-hand, current head 9be5fd1bd)
I re-verified both fixes from this round from scratch, plus the one open question I'd flagged.
P1 — parent-task deferral gate (task-agent-manager.ts:3627-3639) — confirmed effective.
The new gate defers an injected external-event/peer-handoff message when the parent task is rate_limited/usage_limited. I raised whether findParentTaskIdForSubSession (:3729) — which iterates the in-memory this.subSessions map and returns null on miss — actually works in the cross-restart window this fix targets (rehydrate flips persisted rate_limit_cooldown → idle, so inRateLimitCooldown alone is false until the tick sweep re-arms the cooldown).
It holds. injectMessageIntoSession(session: AgentSession, …) only ever runs against an already-in-memory session, and a session reaches memory solely via createSubSession (:1393) or rehydrateSubSession (:3355) — both populate this.subSessions (taskId → sessionId) before the session can process a message. So the lookup is guaranteed to resolve for any session passed in, and parentTask = taskRepo.getTask(parentTaskId) is a DB-backed read → authoritative status. Across the post-restart sub-windows the gate behaves correctly: pre-sweep (parent still rate_limited, session idle) → deferred via parentLimited; post-sweep-future-resetAt → deferred via inRateLimitCooldown; post-sweep-past-resetAt (task restored) → delivered, which is correct. The in-memory implementation is coherent with the in-memory precondition, not a gap.
P2 — fireImmediateFallback recheck (rate-limit-watchdog.ts:605-608) — confirmed.
Re-checks episodeGeneration !== this.generation after the resolveModelId await (:599) and before triedKeys.add (:609) / the scheduleRetry re-entry (:615), so a superseding turn during the await can no longer poison the new episode's triedKeys set after its clear (:273).
Convergence assessment
Agree the generation-guard concurrency theme is now closed. All side-effect commit points carry the captured-episodeGeneration re-check on this head:
- watchdog:
scheduleCooldown(:403),fireCooldownRetry(:564), startup-retry (:523→:531),fireImmediateFallbackre-entry (:586) + post-resolve (:605) agent-session:switchAndRetryForFallbackpost-queryPromise(:894) / post-handleModelSwitch(:917);executeRateLimitAutoRetry(:989); delegation (:1603)QueryLifecycleManager.startQueryAndEnqueuecommit-point guard beforeenqueueWithId(:670), ctx-wired + opt-in (:94)
CI / merge state on 9be5fd1bd
All CI checks SUCCESS (lint/typecheck, full daemon online+unit matrix, web tests, coverage gate, "All Tests Pass"), 4 matrix jobs SKIPPED (non-triggered). PR OPEN / MERGEABLE / CLEAN / base dev / not draft. All 73 review conversations resolved (0 unresolved, 0 open-and-current).
Residual nit (non-blocking, P3)
The deferred-P2s are acceptable to defer — no correctness/safety impact, and they're edge-case hardening. One repeat note (carried from prior rounds, still non-blocking): the tracking ticket cited for them ("#812") maps to an unrelated merged "display short IDs in task cards" PR in this repo, so the citation is ambiguous to outside readers. If it refers to an internal NeoKai space task, a one-word qualifier ("space task #812") would remove the ambiguity. Not gating.
Verdict
All P0–P3 findings from this and prior rounds are either fixed and re-verified or explicitly deferred with acceptable rationale. CI is green, merge state is clean, every review conversation is resolved. Approving.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 9be5fd1bde
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
…-task spawn + full backoff ladder (codex round 6) Address codex's round-6 review on head 9be5fd1: P1 — supersede recovery when genuine new input arrives: startQueryAndEnqueue now distinguishes the two callers via episodeGeneration. Genuine new user input (undefined) calls watchdog.cancel() — bumping the generation so an in-flight fallback switch / cooldown-retry callback aborts and doesn't replay the stale message alongside the new turn (the old clearPendingCooldown-for-both design conflated the cases). Internal recovery re-enqueue (episodeGeneration provided, same episode) keeps clearPendingCooldown (no self-abort). Tests: cancel for undefined; clearPendingCooldown for recovery. P2 — keep limited-task spawn rejection recoverable: validateTaskAllowsSpawn now throws a new TransientSpawnError (not PermanentSpawnError) for rate/usage-limited tasks. The runtime recognizes it and skips the spawn (leaving the execution pending) instead of permanently cancelling/unregistering it — so a transient cooldown no longer permanently removes the target agent; the next tick re-attempts after recoverRateLimitedTasks restores the task. (Not a crash either: doesn't consume a crash-retry.) P2 — make the full backoff ladder reachable: DEFAULT_CONFIG.maxAutoRetries is now BACKOFF_LADDER_MS.length (5), so a no-reset-timestamp 429 can reach the 2h/4h steps before recovery goes terminal (previously the default budget of 3 capped it at 1h). Parsed-reset waits remain free. Test: default maxRetries equals the ladder length.
lsm
left a comment
There was a problem hiding this comment.
🤖 Review by glm-5.1 (NeoKai)
Model: glm-5.1 | Client: NeoKai | Provider: Zhipu (GLM)
Recommendation: APPROVE (own-PR, so posted as COMMENT — this is an explicit approval verdict).
Round-12 verification (first-hand, head c1c751ebd) — codex round-6 fixes
P1 — supersede recovery on genuine new input (agent-session.ts:807-829) — confirmed correct.
The branch on episodeGeneration is the right root-level fix for the design flaw the prior generation-guard findings circled:
- Genuine input (
undefined) →watchdog.cancel()— bumpsgenerationso an in-flight fallback switch / cooldown-retry callback (which captured the old generation) seesisSuperseded()and aborts before switching models or replaying the stale message.cancel()also clears the timer/episode message. - Internal recovery (
episodeGenerationprovided) →clearPendingCooldown()— clears only the timer, preserving generation + episode so the in-flight fallback and per-episode tried-set survive.
I traced every caller to confirm the wiring: genuine-input paths omit episodeGeneration (message-persistence.ts:264 — the path the finding named, event-subscription-setup.ts:123, session-handlers.ts:1300, space-agent-tools.ts:1213) → cancel; the sole recovery caller (agent-session.ts:1008 inside executeRateLimitAutoRetry, behind the supersede guard at :996-1002) passes it → clearPendingCooldown. No recovery caller can self-cancel; no genuine caller can fail to supersede.
Two correctness details checked: (1) notifyResume() guards on if (!this.paused) return (:805), so an ordinary user message with no active cooldown produces no spurious resume event — the cancel path is a no-op for resume there. (2) A genuinely new UUID triggers the per-episode reset in scheduleRetry (:276-283, clears triedKeys/chain/retryCount/startupRetries), so the new turn gets the full fallback chain rather than the prior turn's exhausted one. This also closes the related "resume the task when new input replaces a cooldown" P2 — cancel()'s notifyResume ends the old pause.
P2 — TransientSpawnError for limited-task spawn — confirmed correct.
Delegated a focused trace. The spawn-loop catch (space-runtime.ts:8086-8130) orders guards correctly: cancelExecutionForPermanentSpawnError early-returns false for non-permanent (:8200), then isTransientSpawnError → continue (:8097-8101) lands before the generic crash-retry path (:8117), so a transient cap neither consumes a MAX_TASK_AGENT_CRASH_RETRIES budget nor cancels/unregisters the execution. No infinite-loop or orphan risk: processRunTick early-returns for rate_limited/usage_limited tasks (:7619-7621) so the tick never re-spawns while paused, and recoverRateLimitedTasks restores (resetAt passed) → next tick re-spawns the pending execution. TransientSpawnError extends Error (not PermanentSpawnError), instanceof checks are isolated, no property-style checks exist. The path is only reachable via out-of-band activation (tick early-returns first), matching the validator's doc comment.
P2 — full backoff ladder reachable — confirmed correct.
BACKOFF_LADDER_MS = [10m, 30m, 1h, 2h, 4h] (fallback-recovery.ts:27-33, 5 entries; BACKOFF_CAP_MS=8h so the 4h step isn't clipped). maxAutoRetries: BACKOFF_LADDER_MS.length (=5). Budget boundary (!freeWait && retryCount >= maxAutoRetries) lets retryCount 0→4 select ladder[0..4] (all five steps) before going terminal at 5; !decision.freeWait gates both the budget check and the increment, so parsed-reset waits stay free. Exactly the intent.
Deferred findings (the 3 codex posted at 21:23)
Not actually outstanding on this head:
- P1 "Preserve cooldown when lazily rehydrating sessions" (
task-agent-manager.ts:3627) — this is the round-11 parent-task gate (parentLimitedat :3634, gate at :3636), present on this head. Fixed. - P2 "Recheck the episode after canonicalizing a failed fallback" (
rate-limit-watchdog.ts:~610) — the post-resolveModelIdrecheck beforetriedKeys.add, present on this head. Fixed. - P2 "Revalidate dependencies before resuming a limited task" (
task-agent-manager.ts:753) — genuinely deferred edge case (add unmet dependency mid-cooldown, resume ignores it). P2-level, not safety-critical; acceptable to track in #811/#812.
CI / merge state on c1c751ebd
All required checks SUCCESS ("All Tests Pass", 0 failures, 0 incomplete). PR OPEN / MERGEABLE / CLEAN / base dev / not draft. All 73 review conversations resolved (0 open).
Residual nits (non-blocking, P3)
- No unit test for the
TransientSpawnErrorpath. Existing spawn-failure tests cover onlyPermanentSpawnError. Given the subtle error-type ordering (transientcontinuebefore crash-retry, out-of-band-only reachability) and the handshake withrecoverRateLimitedTasks, a regression test asserting (a) a limited task's execution stayspendingafter an out-of-band activation, and (b) re-spawns after recovery, would be worth adding. Coverage recommendation, not a defect. - #811/#812 citation (carried from prior rounds): the "remaining task-lifecycle follow-ups" tracking ticket citation remains ambiguous to outside readers — please confirm it refers to internal space tasks (not GitHub issues) so the deferral is traceable. Not gating.
Verdict
The root design flaw (genuine-input supersede vs. internal-recovery re-enqueue conflated at startQueryAndEnqueue) that drove much of the round 2–6 generation-guard back-and-forth is corrected at its source, and all three round-6 fixes verify first-hand. All P0–P2 findings are fixed-and-verified or acceptably deferred; only two P3 coverage/process nits remain, both explicitly non-blocking. CI green, merge state clean, every review conversation resolved. Approving — convergence is the right call here; the human can weigh the #811/#812 task-lifecycle follow-ups against shipping now.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: c1c751ebd0
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
…sume + spawn re-fetch (codex round 7) Address codex's round-7 review on head c1c751e (1 P1 + 2 P2 fixed; 1 P2 deferred to #812): P1 — route resettable quota 429 quotas through recovery: isBillingError (query-runner) matched 'quota exceeded' / 'insufficient_quota' unconditionally, so a genuine '429 quota exceeded ... resets at <ts>' was terminal-billing and onRateLimitExhausted was never invoked — leaving the reset parser + usage-limit classification unreachable for English quota-429s (a primary case). Extracted a tested pure helper isNonRetryableBillingError (fallback-recovery): 402 and quota phrases are billing UNLESS the message carries a resettable timestamp (a future reset window), in which case it's a cap recovery can wait out. Tests: 402 always billing; quota without reset → billing; quota WITH reset → recovery; plain 429 → not billing. P2 — use watchdog state when manually resuming cooldowns: resumeRateLimitedSubSession gated on the volatile rate_limit_cooldown processing state, which query-runner's unconditional setIdle overwrites to idle; a manual resume of an ordinary cooldown (watchdog timer armed, session idle) skipped retryNow. retryNowAfterRateLimit now returns whether it fired (retryNow self-gates on the watchdog's pending/startup-exhausted state) and resumeRateLimitedSubSession drops the volatile-state gate. P2 — re-read the parent task before validating spawn: validateTaskAllowsSpawn's caller re-fetches the task from taskRepo at the spawn-commit point — a parallel node may have hit a limit after the caller loaded its snapshot, flipping the DB row to rate_limited while the snapshot still says in_progress (which would spawn a worker during the cooldown). Deferred to #812: the round's other P2 (suppress terminal errors from speculative fallback switches — needs a non-terminal ModelSwitchHandler mode + error-listener coordination).
lsm
left a comment
There was a problem hiding this comment.
🤖 Review by glm-5.1 (NeoKai)
Model: glm-5.1 | Client: NeoKai | Provider: Zhipu (GLM)
Recommendation: APPROVE (own-PR, so posted as COMMENT — this is an explicit approval verdict).
Round-13 verification (first-hand, head 2b612a7d6) — codex round-7 fixes
P1 — route resettable quota 429s through recovery (fallback-recovery.ts:351-378 + query-runner.ts:1326-1334) — confirmed correct.
This was a genuine primary-case feature gap: the old inline isBillingError matched quota exceeded / insufficient_quota / no quota unconditionally, so a 429 quota exceeded … resets at <ts> was terminal-billing and onRateLimitExhausted was never called — leaving the reset parser + usage-limit classification unreachable for English quota-429s. The extracted isNonRetryableBillingError carves out the resettable case: 402 is always billing; quota phrases are billing unless extractResetTimestamp finds a reset window, in which case it routes to recovery.
The safety of this hinges on no false-positive rescuing a genuine billing error — and it holds: extractResetTimestamp returns non-null only when some candidate passes isValidReset, which is ms > now && ms < now + MAX_RESET_HORIZON_MS (strictly future, within horizon). So a billing error with no reset, or with only a past request timestamp, yields resettable=false → billing. 4 tests pin the matrix: 402-always-billing (even with a timestamp), quota-no-timestamp→billing, quota+timestamp→recovery, plain-429→not-billing. The common case (429 without quota phrases) is unchanged (isNonRetryableBillingError → false → recovery).
P2 — manual resume uses watchdog state (agent-session.ts:1045-1055 + task-agent-manager.ts:2251-2266) — confirmed correct, a real improvement.
resumeRateLimitedSubSession no longer pre-checks the volatile rate_limit_cooldown processing state (which query-runner's unconditional setIdle() overwrites to idle on the failed query's finally, so manual resume usually reached the check idle and skipped the retry). retryNowAfterRateLimit now returns whether it fired, and retryNow() self-gates correctly — if (fallbackPending) return false and if (cooldownTimer === null && !startupExhausted) return false — so calling it unconditionally is safe (returns false with no side effect when nothing is pending) and a manual resume now actually restarts work immediately instead of returning "active" while waiting for the original timer.
P2 — re-fetch task at spawn-commit (task-agent-manager.ts:908-916) — confirmed correct.
spawnWorkflowNodeAgentForExecution now does const freshTask = this.config.taskRepo.getTask(task.id) ?? task; validateTaskAllowsSpawn(freshTask); immediately before validating, so a parallel node that flipped the row to rate_limited after the caller loaded task can't slip a stale in_progress snapshot through. The ?? task fallback for a concurrently-deleted row is a harmless edge case.
Deferred to #812 (acceptable)
P2 — suppress terminal errors from speculative fallback switches (agent-session.ts:922): a ModelSwitchHandler internal switch failure broadcasts session.error (which the workflow error listener treats as terminal, marking the node blocked) before returning {success:false}, so even a subsequent successful fallback leaves the task blocked. Needs a non-terminal switch mode + error-listener coordination. Real but narrow (requires an internal switch failure, not the common unavailable-model path), recoverable, and tracked. Fine to defer.
CI / merge state on 2b612a7d6
Required checks all SUCCESS ("All Tests Pass"; 0 failures, no required check incomplete). Greptile Review (a non-required AI reviewer) was still finishing at review time → mergeState UNSTABLE (not failed/blocked). PR base dev, not draft, 80 review conversations all resolved (0 open).
Verdict
The round-7 P1 was a worth-the-catch feature-correctness gap (billing classification swallowed the primary resettable-cap case), now fixed with a tested pure helper and a tight, false-positive-free reset carve-out. Both P2s verify and improve behavior. All P0–P2 findings are fixed-and-verified or acceptably deferred; remaining work is the bounded task-lifecycle follow-ups in #811/#812. Approving — convergence is the right call.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 2b612a7d65
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Greptile SummaryThis PR wires up the previously unused
Confidence Score: 5/5The recovery logic is well-guarded at every async boundary with episode-generation checks; the migration is idempotent and follows established patterns; the cross-restart sweep correctly avoids racing the in-memory watchdog. Safe to merge. The two-phase fallback/cooldown machinery is thoroughly tested, the episode-generation sentinel correctly prevents stale fallback switches or cooldown timers from replaying stopped turns, and all new task-status transitions are consistently propagated across task-manager, goal-service, space-runtime, and UI layers. The only findings are cosmetic: a stale reason string in merged restrictions, and a clearPendingCooldown side-effect that is safe at runtime because a second guard already blocks retryNow in the relevant window. Files Needing Attention: The merge path in task-agent-manager.ts (markTaskRateLimited) and clearPendingCooldown in rate-limit-watchdog.ts are worth a second look, but neither affects correctness.
|
| Filename | Overview |
|---|---|
| packages/daemon/src/lib/agent/fallback-recovery.ts | New pure module for fallback chain resolution, reset-timestamp extraction (4 strategies), backoff computation, and billing-error classification. Well-tested and free of session/DB deps. |
| packages/daemon/src/lib/agent/rate-limit-watchdog.ts | Heavy refactor adding two-phase recovery. Episode-generation guards protect every async boundary. clearPendingCooldown prematurely clears fallbackPending (see comment); safe at runtime. |
| packages/daemon/src/lib/agent/agent-session.ts | Injects deps into RateLimitWatchdog; adds switchAndRetryForFallback and resolveModelIdOrDefault. Watchdog reset gated on result/success SDKMessage only, preventing mid-recovery episode resets. |
| packages/daemon/src/lib/space/runtime/task-agent-manager.ts | Adds rate-limit pause/resume listeners, limitedSessionsByTask tracking, markTaskRateLimited/restoreTaskFromRateLimit, and isSessionInMemory. Fresh-task re-fetch closes the TOCTOU window. Merge path reason field is cosmetically stale (see comment). |
| packages/daemon/src/lib/space/runtime/space-runtime.ts | Adds recoverRateLimitedTasks() cross-restart sweep, rate/usage-limited guard in processCanonicalTaskTick, TransientSpawnError handling, getRunningTaskCount extended to include paused tasks. |
| packages/daemon/src/storage/schema/migrations.ts | Migration 167 rebuilds space_tasks with widened status CHECK and restrictions column. Idempotent, PRAGMA foreign_keys = OFF/ON wraps the DROP correctly. |
| packages/daemon/src/storage/repositories/space-task-repository.ts | Adds listRateLimitedBySpace, restrictions persistence, auto-clear of stale blobs on status transitions, and parseRestrictions with type/resetAt validation. |
| packages/daemon/src/lib/agent/query-runner.ts | Adds looksLikeRateLimit429 to decline 429s from validation handling, and replaces the inline billing check with isNonRetryableBillingError (carves out resettable-timestamp 429s). |
| packages/daemon/src/lib/space/runtime/workflow-node-execution-validation.ts | Adds TransientSpawnError and rate/usage-limit guard in validateTaskAllowsSpawn. Correctly throws Transient so the spawn loop defers without consuming crash retries. |
| packages/shared/src/types/space-utils.ts | isWorkflowRecoveryTransition extended to cover rate_limited/usage_limited → in_progress so manual Resume goes through workflow recovery. |
| packages/web/src/components/space/TaskStatusActions.tsx | VALID_TASK_TRANSITIONS and TRANSITION_LABELS added for rate_limited/usage_limited, matching the backend definition exactly. |
Sequence Diagram
sequenceDiagram
participant QR as QueryRunner
participant WD as RateLimitWatchdog
participant FR as fallback-recovery.ts
participant AS as AgentSession
participant TAM as TaskAgentManager
participant SR as SpaceRuntime tick
QR->>WD: scheduleRetry(errorMessage, lastMsg)
WD->>WD: capture entryGeneration
WD->>FR: resolveChain()
WD->>FR: isEntryAvailable(each entry)
WD->>FR: selectNextFallback(chain, triedKeys, available)
alt Phase A: untried entry available
WD->>WD: "fallbackPending = true"
WD-->>QR: return true skip terminal broadcast
WD->>AS: switchAndRetryForFallback(msg, entry, gen)
AS->>AS: await queryPromise teardown
AS->>AS: handleModelSwitch
AS->>AS: executeRateLimitAutoRetry
alt switch succeeds
WD->>WD: fallbackPending cleared in finally
else switch fails
WD->>WD: triedKeys.add(entry)
WD->>WD: scheduleRetry re-entry
end
else Phase B: chain exhausted
WD->>FR: computeCooldown(errorMsg, retryCount)
FR-->>WD: CooldownDecision
WD->>WD: stateManager.setRateLimitCooldown()
WD->>TAM: notifyPause
TAM->>TAM: markTaskRateLimited
WD->>WD: setTimeout fireCooldownRetry
Note over SR: Each tick
SR->>SR: recoverRateLimitedTasks()
alt resetAt passed and no live session
SR->>SR: reset executions to pending
SR->>SR: restore task to in_progress
end
Note over WD: Timer fires or retryNow
WD->>AS: retryCallback
alt query starts
WD->>TAM: notifyResume()
TAM->>TAM: restoreTaskFromRateLimit
else startup fails
WD->>WD: reschedule short delay
end
end
Reviews (2): Last reviewed commit: "refactor(agent,space): greptile cleanups..." | Re-trigger Greptile
…l-local regex, blocked-scope
Address greptile's 4 P2 review comments (all non-blocking; greptile summary:
4/5 'safe to merge'):
- classifyLimitKind: remove the redundant second CJK keyword pass. The first
pass lowercases both the message and the keyword, which is a no-op for CJK
characters, so it already matches CJK keywords case-insensitively — the raw
second pass was identical dead code.
- rate-limit-watchdog: fix the 'deferrive' typo → 'deferred' (2 log strings).
- LOCAL_DATETIME_RE: accept a bare fractional-second LOCAL datetime
(e.g. '17:55:10.123', truncated to whole seconds) by consuming optional
fractional seconds in the match, while still rejecting a zoned timestamp
(whole or fractional) the ISO pass already handled. The lookahead adds a bare
'\d' term so partial-fraction backtracking ('.00' leaving '0+08:00') can't
bypass the zone check; a trailing sentence '.' is still allowed. Test added.
- shouldStopWorkflowForStatus: restrict the 'blocked' teardown target to
rate/usage-limited origins only, so this PR no longer changes the existing
in_progress → blocked behavior (manually blocking a running task stays out of
scope). The rate-limited → blocked cooldown-teardown use case is preserved.
lsm
left a comment
There was a problem hiding this comment.
🤖 Review by glm-5.1 (NeoKai)
Model: glm-5.1 | Client: NeoKai | Provider: Zhipu (GLM)
Recommendation: APPROVE (own-PR, so posted as COMMENT — this is an explicit approval verdict).
Round-14 verification (first-hand, head 353c505a4) — greptile cleanup round
A cleanup round addressing greptile's 4 non-blocking P2 style/edge comments. All verified:
1. classifyLimitKind CJK dedup (fallback-recovery.ts:343-351) — correct.
The removed raw-message CJK pass was provably redundant: String.toLowerCase() is a no-op on CJK ideographs (no case mapping), so the single lowercased pass (lower.includes(kw.toLowerCase())) is byte-identical to the raw pass for every CJK keyword, and still handles ASCII keywords via the lowercasing. No behavior change.
2. "deferrive" → "deferred" typo (rate-limit-watchdog.ts, 2 log strings) — trivial.
3. LOCAL_DATETIME_RE regex hardening (fallback-recovery.ts:148-164) — verified empirically.
The regex now accepts a bare fractional LOCAL datetime (17:55:10.123) via (\.\d+)? while still rejecting zoned timestamps, and closes a partial-fraction backtracking hole by adding a bare \d to the negative lookahead. I ran the regex against 7 cases directly:
- MATCH (correct):
17:55:10.123,17:55:10,17:55:10.123 重置, trailing sentence period. - NO-match (correct):
11:00:00.000+08:00(the backtracking hole — confirmed closed),11:00:00.000Z,11:00:00+08:00.
Local test shard: 47/47 pass (incl. the new bare-fractional-LOCAL test). The mechanism: greedy(\.\d+)?consumes the full fraction then the[+-]\d{2}/Zlookahead rejects a zone; every backtracking shorten-path now trips the added\d(or\.\d) lookahead, so a zoned timestamp can't be partially rescued as local.
4. shouldStopWorkflowForStatus blocked-scope restriction (space-task-handlers.ts:460-482) — correct.
The blocked teardown target is now restricted to rate/usage-limited origins (toBlockedFromPaused), so rate_limited/usage_limited → blocked still tears down the armed cooldown session (the earlier "stop the cooldown when manually blocking a limited task" fix is preserved), while in_progress → blocked no longer triggers teardown — keeping this PR's blast radius scoped to the rate-limit work and leaving the pre-existing in_progress → blocked behavior unchanged. toStopped (open/cancelled) teardown from any active/paused origin is unchanged.
Codex P2 (acknowledged, not a regression)
recoverWorkflowBackedTask commits in_progress before the retry outcome — this is the previously-deferred #812 item (the "await a successful retry before restoring the task" finding), acknowledged in-thread and consolidated into #812. Not a new regression introduced this round.
CI / merge state on 353c505a4
Required checks all SUCCESS (0 failures; local test shard 47/47). Greptile Review (non-required AI reviewer) still finishing → mergeState UNSTABLE (not failed/blocked). PR base dev, not draft, 85 review conversations all resolved (0 open).
Verdict
Clean, low-risk cleanup round; every change verified first-hand (regex empirically + local tests). Two independent reviewers (codex across 7 rounds, greptile 4/5 merge-ready) now align on merge-readiness, with all P1s fixed and remaining work consolidated into the bounded #811/#812 task-lifecycle follow-ups. Approving — converge at the next fully-settled CI.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 353c505a44
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
…n + partial-resume recompute Two P2 task-lifecycle edge cases deferred from #2271 (codex round-4 review), revised through two rounds of review. Finding A — banner Cancel left the task unrecoverable in-process. After cancelRateLimitRetry (cancel(false) + setIdle), retryNow could not re-fire (timer dropped, startupExhausted cleared) and the cross-restart recoverRateLimitedTasks sweep skipped the live idle session, so the visible Resume could not restart the consumed turn until a daemon restart. resumeRateLimitedSubSession now detects the parked state via a narrow `bannerCancelled` flag (set only in cancel(notifyResume=false), cleared on the next pause/resume/new-episode/reset — NOT the raw `paused` flag, which is also true mid-fireCooldownRetry while an auto-retry is actively starting) and re-spawns the execution (reset to pending + clear agentSessionId, stop + evict the orphaned idle session) so the workflow tick spawns a fresh replacement. recoverWorkflowBackedTask skips the now-redundant prepare step on respawn. Finding B — merged restriction was not recomputed on partial session resume. limitedSessionsByTask was IDs-only and the resume listener left the merged restriction untouched when other sessions remained, so the latest-deadline session resuming first left a stale later resetAt (delaying cross-restart recovery) and the status stayed at the stronger kind. Changed to Map<taskId, Map<sessionId, {resetAt,kind,reason}>> and recompute the merged restriction from all/remaining entries on every pause and resume, keeping the persisted restriction consistent with the in-memory set. The change-skip guard compares status/resetAt/type/limit so a re-pause that only flips the reason persists. The banner-cancelled session's limitedSessionsByTask entry is cleared by the session.rate_limit_resume event published synchronously inside stopSessionPreserveDb → handleInterrupt → cancel(notifyResume=true) → notifyResume → InternalEventBus.publish (verified: the bus delivers synchronously, notifyResume uses publish), which runs the resume listener before eviction while subSessions still holds the session — no explicit cleanup needed. Tests: multi-session partial-resume recompute; isRateLimitBannerCancelled state-machine (idle / armed cooldown is not a banner / cancel(false) sets it / cancel(true) + new episode clear it); respawn on Resume (respawned / retried / noop / noop-missing / no-respawn-during-in-flight-retry); resume-event-clears- entry (realistic handleInterrupt publishing the resume event).
Sessions hitting 5h/weekly usage caps (e.g. third-party relay
429with a reset timestamp) never recovered: the watchdog retried the same model at a fixed 10min ×3 then gave up, andGlobalSettings.fallbackModels/modelFallbackMaphad no runtime consumers.This makes the configured fallback chain real and switches resets to format-agnostic parsing:
modelFallbackMap[provider/model]?? globalfallbackModels, switch to the next untried available entry via the existing model-switch machinery, and retry immediately. Repeated 429s advance through the chain; switches are free (don't count towardmaxAutoRetries).YYYY-MM-DD HH:mm:ssincl. the Chinese relay message, epoch s/ms) and wait until reset + buffer; otherwise use a backoff ladder (10m→4h, cap 8h, jitter). Reset-known waits don't count toward the retry budget.rate_limited/usage_limitedwith arestrictions.resetAtblob (migration 167) and auto-resumes toin_progresswhen the limit lifts.Pure recovery logic lives in a new
fallback-recovery.tsmodule (fully unit-tested); the watchdog is refactored to take injected deps.query-runner.ts/model-switch-handler.tsare unchanged — the existing skip-error-broadcast gate already prevents task failure on recovery.