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.
-
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.
-
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.
Summary
A worker session can keep showing
Needs inputeven after it has opened a clean, mergeable PR. The concrete case is sessionall-email-aggregator-4for 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:
Local AO PR aggregate row:
Live GitHub state from
gh pr view 34 --repo harshitsinghbhandari/emagg:The local
pr_checkstable had several check rows updated topassed, but the aggregatepr.ci_stateremainedpending, and the aggregatepr.mergeabilityremainedunstable. 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
deriveStatuschecksactivity == waiting_inputbefore PR readiness:Relevant code:
backend/internal/service/session/status.gobackend/internal/adapters/agent/claudecode/activity.gobackend/internal/observe/scm/observer.gobackend/internal/adapters/scm/github/observer_provider.goExpected Behavior
Once a worker has handed off a clean open PR, the wall should not keep nagging with
Needs inputjust 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:If the activity model intentionally treats every Claude
idle_promptaswaiting_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:
The repo open PR list is guarded with REST
pulls?state=open&sort=updated&direction=desc&per_page=1. GitHub PRupdatedAtcan stay unchanged while checks and mergeability settle. For PR #34, live GitHub showedmergeStateStatus=CLEAN, but the persisted row stayed at the earlierUNSTABLEsnapshot.The commit check-runs guard uses
commits/{sha}/check-runs?per_page=1, which may not be a complete proxy for GraphQLstatusCheckRollup, 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/unstableobservation and may not converge topassing/mergeable.2. Claude
idle_promptis stickywaiting_inputClaude Code activity maps notification types
idle_promptandpermission_prompttoActivityWaitingInput:waiting_inputis 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_inputtoNeeds inputand 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
Proposed Fix Direction
This probably needs a two-part fix.
Make PR observations converge after transient GitHub states.
ci=pending|unknownormergeability=unstable|unknown.pr.ci_stateandpr.mergeabilityare recomputed from the latest full GraphQL observation, not inferred from partial check-row updates.Separate Claude idle prompts from real user-blocking prompts.
permission_promptaswaiting_input.idle_promptshould map toidle, or whether status derivation should let a clean PR outrankwaiting_inputwhen the only source is a post-turn idle prompt.waiting_input.Acceptance Criteria
ready.needs_input.needs_inputand remain attention-worthy.pending/unstable, laterpassing/mergeable, and status transitions toready.permission_promptremainswaiting_input.idle_promptdoes not incorrectly preventreadyafter clean PR handoff.Notes
This is related to the status model in discussion #332: #332
The #332 model says
Readyis for clean PRs (mergeable,approved, orreview_required) andNeeds inputis 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.