Skip to content

fix(scheduler): mark unresumable + exhausted agents as terminal (stop re-processing) - #1195

Merged
namastex888 merged 1 commit into
devfrom
fix/wedged-terminal-state
Apr 18, 2026
Merged

namastex888 merged 1 commit into
devfrom
fix/wedged-terminal-state

Conversation

@namastex888

Copy link
Copy Markdown
Contributor

Summary

Two related bugs, one cohesive PR — both variants of terminal-state agents the scheduler kept re-processing every cycle.

  • Bug A (Category 2 /trace, HIGH confidence): Rows in state='error', auto_resume=true, claude_session_id=null are permanently wedged — the resumable filter drops them (no session id), yet they keep auto_resume=true forever. 9 such rows observed live on felipe's machine (8 genie-docs dir-placeholders + 2 omni workers that died pre-session-capture). genie ls misleadingly showed auto-resume: on + 0/3 forever.
  • Bug B (observed live): The attempts >= maxAttempts early-exit in attemptAgentResume logged agent_resume_exhausted and returned, but did NOT persist auto_resume=false. Next tick (60s) re-entered the same branch → redundant event spam for every exhausted agent, every minute, forever.

Both fixes apply the same terminal-boundary invariant: set auto_resume=false at the point the scheduler concludes the agent is unrecoverable. Shared primitive (deps.updateAgent), shared filter semantics (the autoResume === false early-skip already exists in attemptAgentResume).

Files touched

  • src/lib/scheduler-daemon.ts — new exported reconcileUnresumable(deps), wired into startAgentResumeTimer between the existing zombie GC and the resume pass. Both exhaustion branches in attemptAgentResume now await deps.updateAgent(id, { autoResume: false }) before logging.
  • src/lib/scheduler-daemon.test.ts — 4 new regression tests + 1 existing test updated to assert the new terminal-flag write.

Prior art

This is the final cleanup pass closing the "terminal states should be terminal" theme.

Staged follow-ups (explicitly out of scope)

  • Root cause for how dir: registry rows end up in state='error' — separate /trace (tui-spawn --team is required failure path)
  • Fix for why omni transport agents don't capture claudeSessionId — separate /trace
  • Ops cleanup of the 9 already-wedged rows on felipe's live machine — handled by the new reconciler on next daemon restart

Test plan

  • bun run typecheck — 0 errors
  • bun run check — 2668 pass / 0 fail
  • New regression test: reconcileUnresumable flips null-session error rows + logs agent_marked_unresumable
  • New regression test: reconcileUnresumable leaves valid-session / already-disabled / healthy rows untouched
  • New regression test: exhaustion branch persists auto_resume=false alongside the agent_resume_exhausted log event
  • New regression test: subsequent runAgentRecoveryPass tick does NOT re-log agent_resume_exhausted for the same agent
  • Existing test exhaustion check fires on re-entry updated: now asserts exactly 1 updateAgent call with autoResume: false + no counter mutation
  • Post-merge dogfood: genie ls | grep -E "genie-docs|omni" should flip to auto-resume: off within one leaseRecoveryIntervalMs cycle; tail -30 ~/.genie/logs/scheduler.log | grep agent_resume_exhausted | wc -l should show each agent ONLY ONCE

🤖 Generated with Claude Code

… re-processing)

Two related bugs, same architectural shape — terminal-state agents that the
scheduler kept re-processing every cycle.

Bug A: Rows in `state='error', auto_resume=true, claude_session_id=null`
were dropped by the resumable filter (no session id to resume), but stayed
in `auto_resume=true` forever — misleading `genie ls` ("auto-resume: on")
and cluttering the worker list. Observed live on felipe's machine: 9 wedged
rows (8 genie-docs directory placeholders + 2 omni workers that died before
capturing a Claude session).

Fix: new `reconcileUnresumable` pass wired into `startAgentResumeTimer`
between `reconcileDeadPaneZombies` and `runAgentRecoveryPass`. Flips those
rows to `auto_resume=false` and logs `agent_marked_unresumable`.

Bug B: The `attempts >= maxAttempts` early-exit in `attemptAgentResume`
logged `agent_resume_exhausted` and returned, but did NOT persist
`auto_resume=false`. Next scheduler tick (60s later) re-entered the same
branch and re-logged the same event — the agent was never re-attempted,
yet the log stayed polluted with redundant exhaustion events forever.

Fix: both exhaustion branches (pre-attempt early-exit + post-attempt final
failure) now call `deps.updateAgent(agentId, { autoResume: false })` before
logging. The next cycle's `attemptAgentResume` short-circuits at the
`autoResume === false` check with `agent_resume_skipped` (debug level),
ending the every-60s spam.

Prior art: PR #1181 (zombie concurrency cap), PR #1187 (counter
persistence). This closes the "terminal states should be terminal" theme.

Staged follow-ups (out of scope):
- dir:-row state mutation root cause (separate /trace)
- omni session-capture gap (separate /trace)

Regression tests (4 new + 1 updated in scheduler-daemon.test.ts):
- reconcileUnresumable flips null-session error rows to auto_resume=false
- reconcileUnresumable leaves valid-session + already-disabled + healthy rows untouched
- attemptAgentResume exhaustion branch persists auto_resume=false + logs event
- Subsequent runAgentRecoveryPass tick does not re-log agent_resume_exhausted
- Existing `exhaustion check fires on re-entry` test updated to assert the new
  terminal-flag write (autoResume=false, no counter mutation on early-exit)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Apr 18, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 0914c326-94ea-441a-9067-168b65167ee8

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/wedged-terminal-state

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request addresses issues with agents getting stuck in terminal states by ensuring that agents in an error state without a session ID, or those that have exhausted their retry budget, are correctly marked with autoResume=false. This prevents redundant logging and ensures the UI accurately reflects the agent's status. The feedback suggests optimizing the reconciliation process by performing database updates in parallel and ensuring the new reconciliation pass is also executed during the daemon's startup recovery sequence.

Comment on lines +841 to +870
for (const worker of workers) {
// Terminal boundary condition: error-state, auto-resume on, no session id.
// `!worker.claudeSessionId` matches both `null` (DB) and `undefined`
// (listWorkers mapping for missing column).
if (worker.state !== 'error') continue;
if (worker.autoResume === false) continue;
if (worker.claudeSessionId) continue;

try {
await deps.updateAgent(worker.id, { autoResume: false });
deps.log({
timestamp: deps.now().toISOString(),
level: 'warn',
event: 'agent_marked_unresumable',
agent_id: worker.id,
reason: 'no_session_id',
state: worker.state,
});
flipped++;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
deps.log({
timestamp: deps.now().toISOString(),
level: 'warn',
event: 'agent_marked_unresumable_failed',
agent_id: worker.id,
error: message,
});
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The current implementation processes unresumable agents sequentially using await inside a loop. If a systemic issue causes a large number of agents to become wedged, these sequential database updates could significantly delay the completion of the reconciliation pass and the subsequent recovery pass. Consider using Promise.all to perform these updates in parallel for better efficiency, as each agent update is independent.

// null session ids stayed in `auto_resume=true` forever, misleading
// `genie ls` and cluttering the worker list.
await reconcileDeadPaneZombies(d);
await reconcileUnresumable(d);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

While reconcileUnresumable is correctly added to the periodic timer, it appears to be missing from the recoverOnStartup function. This means that after a daemon restart, wedged agents will continue to show as 'auto-resume: on' in the UI until the first timer tick (default 60s). Adding this pass to recoverOnStartup would ensure the state is reconciled immediately upon startup, providing a more accurate UI experience.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7624e609fd

ℹ️ 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".

// `attempts >= maxAttempts` branch and re-log `agent_resume_exhausted` every
// 60s forever.
if (newAttempts >= maxAttempts) {
await deps.updateAgent(agentId, { autoResume: false });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reset auto-resume after successful manual resume

Persisting autoResume: false on exhaustion here makes the disablement sticky across manual recovery: genie agent resume re-registers the worker using the existing agent.autoResume value, so a successfully resumed agent stays opted out of future scheduler retries. Because the CLI resume path only resets resumeAttempts (not autoResume), an agent that exhausts once can silently lose auto-recovery forever unless the flag is manually edited in storage; this is a behavioral regression from before this commit.

Useful? React with 👍 / 👎.

@namastex888
namastex888 merged commit 75f2994 into dev Apr 18, 2026
6 checks passed
namastex888 pushed a commit that referenced this pull request Apr 18, 2026
…y spam

Two retry-forever spam bugs in the observability loops — both follow the
pattern we've been closing all session: recoverable-looking error on a
fast-polling path with no circuit breaker → same log line every 30s.

Bug A — filewatch FK violation (session-filewatch.ts)
  Orphan subagent JSONLs (parent session not in PG sessions table, e.g.
  SDK agents) made ingestFileFull raise FK constraint 23503. The catch
  block logged but did NOT advance offsetCache, so every subsequent
  fs.watch event retried the same file at offset 0 → same FK error →
  same log. Forever.

  Fix: detect FK (code 23503 or "foreign key constraint" in message),
  pin offsetCache to Infinity AND add the session to an unrecoverable
  set so future events short-circuit before touching ingest. Non-FK
  errors (connection reset, deadlock) still preserve retry semantics.

Bug B — inbox-watcher missing workingDir (inbox-watcher.ts)
  checkInboxes iterates teams with unread messages. If workingDir is
  missing from a team's config, it warned every poll cycle — same 3-4
  teams re-warning every 30s forever (live-observed on felipe).

  Fix: mirror the existing spawnFailures rate-limit — new
  noWorkingDirWarned Map warns once per team, silences for 1 hour, then
  re-fires (so configs that get populated upstream naturally recover).

Pattern continuity with #1187/#1192/#1195 — same shape, same fix class:
observability path sees recoverable-looking condition → no circuit
breaker → floods logs.

Staged follow-up (out of scope): separate /trace needed upstream on
why teams register without workingDir (team-config creation bug).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@automagik-genie
automagik-genie deleted the fix/wedged-terminal-state branch September 25, 2026 04:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant