Skip to content

Session shows Needs input after clean PR because SCM and activity facts stay stale #352

@harshitsinghbhandari

Description

@harshitsinghbhandari

This was generated by AI during triage.

Summary

A worker session can keep showing Needs input even after it has opened a clean, mergeable PR. The concrete case is session all-email-aggregator-4 for PR https://github.com/harshitsinghbhandari/emagg/pull/34.

This appears to be a backend fact freshness/status derivation problem, not a simple frontend rendering mismatch. The UI is rendering the backend-derived stale result.

Concrete Evidence

Local AO DB row for the session:

session id: all-email-aggregator-4
activity_state: waiting_input
is_terminated: false
branch: ao/all-email-aggregator-4/root
first_signal_at: 2026-06-20 19:14:41 UTC
activity_last_at: 2026-06-20 19:20:38 UTC

Local AO PR aggregate row:

url: https://github.com/harshitsinghbhandari/emagg/pull/34
pr_state: open
ci_state: pending
review_decision: none
mergeability: unstable
provider_mergeable: MERGEABLE
provider_merge_state_status: UNSTABLE
observed_at: 2026-06-20 19:20:03 UTC

Live GitHub state from gh pr view 34 --repo harshitsinghbhandari/emagg:

state: OPEN
isDraft: false
mergeable: MERGEABLE
mergeStateStatus: CLEAN
checks: gitleaks, typecheck, lint, test, CodeRabbit all SUCCESS
reviewDecision: empty/none

The local pr_checks table had several check rows updated to passed, but the aggregate pr.ci_state remained pending, and the aggregate pr.mergeability remained unstable. The status derivation reads the aggregate PR row, not the check rows.

Actual Behavior

The dashboard shows the worker as Needs input.

That is explainable from the stored facts because deriveStatus checks activity == waiting_input before PR readiness:

case rec.Activity.State == domain.ActivityWaitingInput:
    return domain.StatusNeedsInput

Relevant code:

  • backend/internal/service/session/status.go
  • backend/internal/adapters/agent/claudecode/activity.go
  • backend/internal/observe/scm/observer.go
  • backend/internal/adapters/scm/github/observer_provider.go

Expected Behavior

Once a worker has handed off a clean open PR, the wall should not keep nagging with Needs input just because Claude emitted a normal post-turn idle prompt.

For the PR #34 case, the expected display state should be Ready, because the live PR is clean and mergeable:

activity: effectively idle / post-turn
PR: open, non-draft, mergeable, clean, all checks passing
status: Ready

If the activity model intentionally treats every Claude idle_prompt as waiting_input, then clean PR readiness can never surface after a Claude worker finishes a turn and emits that notification. That seems wrong for the dashboard's “whose move is it?” model.

Suspected Root Causes

1. SCM observer skips full PR refresh after GitHub settles mergeability/checks

The SCM observer is configured to poll every 30 seconds, but it only runs the full GraphQL PR refresh when candidate selection thinks something changed.

Candidate selection currently depends on:

  • missing local state
  • repo open-PR-list ETag change
  • per-commit check-runs ETag change

The repo open PR list is guarded with REST pulls?state=open&sort=updated&direction=desc&per_page=1. GitHub PR updatedAt can stay unchanged while checks and mergeability settle. For PR #34, live GitHub showed mergeStateStatus=CLEAN, but the persisted row stayed at the earlier UNSTABLE snapshot.

The commit check-runs guard uses commits/{sha}/check-runs?per_page=1, which may not be a complete proxy for GraphQL statusCheckRollup, external statuses, or mergeability. A PR can move from pending/unstable to passing/clean without this guard causing a full PR refresh.

Result: AO persists the first transient pending/unstable observation and may not converge to passing/mergeable.

2. Claude idle_prompt is sticky waiting_input

Claude Code activity maps notification types idle_prompt and permission_prompt to ActivityWaitingInput:

case "idle_prompt", "permission_prompt":
    return domain.ActivityWaitingInput, true

waiting_input is sticky, so once the worker finishes and emits an idle prompt, the status derivation treats it as needing the human, even if the useful human action is actually to merge/review the PR.

Permission prompts and idle prompts probably need different semantics. A permission prompt is genuinely blocked on the user. A normal idle prompt after a completed turn may just mean the agent is idle/alive, not that the dashboard should nag.

3. Backend and frontend are both doing the “right” thing with stale facts

The frontend status metadata maps needs_input to Needs input and marks it as attention-worthy. The UI is not inventing the state; it is rendering the backend-provided status.

So fixing only frontend rendering would hide the underlying stale/incorrect domain facts.

Reproduction / Inspection Commands

sqlite3 -header -column ~/.ao/data/ao.db \
  "select id, activity_state, activity_last_at, is_terminated, branch, first_signal_at from sessions where id='all-email-aggregator-4';"

sqlite3 -header -column ~/.ao/data/ao.db \
  "select url, pr_state, ci_state, review_decision, mergeability, provider_mergeable, provider_merge_state_status, observed_at, ci_observed_at, updated_at from pr where session_id='all-email-aggregator-4';"

sqlite3 -header -column ~/.ao/data/ao.db \
  "select name, status, conclusion, commit_hash, created_at from pr_checks where pr_url='https://github.com/harshitsinghbhandari/emagg/pull/34' order by name;"

gh pr view 34 --repo harshitsinghbhandari/emagg \
  --json number,state,isDraft,mergeable,mergeStateStatus,reviewDecision,statusCheckRollup,updatedAt,url,headRefName,baseRefName

Proposed Fix Direction

This probably needs a two-part fix.

  1. Make PR observations converge after transient GitHub states.

    • Do not rely only on repo PR-list ETag and a one-item check-runs ETag to decide whether to refresh a tracked open PR.
    • Consider force-refreshing tracked PRs while local aggregate facts are transient: ci=pending|unknown or mergeability=unstable|unknown.
    • Continue polling such PRs until they become terminal/actionable/stable: passing+mergeable, failing, conflicting, draft, closed, merged, etc.
    • Ensure aggregate pr.ci_state and pr.mergeability are recomputed from the latest full GraphQL observation, not inferred from partial check-row updates.
  2. Separate Claude idle prompts from real user-blocking prompts.

    • Treat permission_prompt as waiting_input.
    • Reconsider whether idle_prompt should map to idle, or whether status derivation should let a clean PR outrank waiting_input when the only source is a post-turn idle prompt.
    • If activity needs to preserve both cases, introduce a more specific activity/reason field instead of overloading waiting_input.

Acceptance Criteria

  • A worker that opens a PR while GitHub initially reports pending/unstable eventually updates to passing/mergeable without daemon restart or manual refresh.
  • The derived session status for a stopped/idle worker with a clean PR becomes ready.
  • A normal Claude post-turn idle prompt does not permanently hide a clean PR behind needs_input.
  • Real permission prompts still show needs_input and remain attention-worthy.
  • Tests cover:
    • PR initially pending/unstable, later passing/mergeable, and status transitions to ready.
    • Check rows changing without aggregate PR row freshness does not leave status stale.
    • Claude permission_prompt remains waiting_input.
    • Claude idle_prompt does not incorrectly prevent ready after clean PR handoff.

Notes

This is related to the status model in discussion #332: #332

The #332 model says Ready is for clean PRs (mergeable, approved, or review_required) and Needs input is for the agent blocked on the human. The PR #34 case exposes that our stored facts can make the derivation choose the wrong human move.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingfrontendElectron frontend lanelcm-smLifecycle + Session Manager laneneeds-triageMaintainer needs to evaluate this issuescmSCM observer/notifier lane

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions