Skip to content

fix(codex): retain completion evidence for fresh status polls - #1795

Open
Asentient wants to merge 2 commits into
asheshgoplani:mainfrom
Asentient:fix/codex-status-evidence
Open

fix(codex): retain completion evidence for fresh status polls#1795
Asentient wants to merge 2 commits into
asheshgoplani:mainfrom
Asentient:fix/codex-status-evidence

Conversation

@Asentient

@Asentient Asentient commented Jul 30, 2026

Copy link
Copy Markdown

What problem does this solve?

Fresh agent-deck list --json processes can report a completed Codex session as
running forever because every process forgets the first half of the
running-to-waiting debounce. Fixes #1792.

Why this change

Codex completion hooks are explicit evidence, but the current status file keeps
only the latest event and orders events with Unix-second timestamps. This
extends that file compatibly with a monotonic generation plus retained
last-started/last-completed generations bound to one Codex thread.

Hook writers now serialize read-modify-write with a per-instance flock and
randomized same-directory atomic replacement. A stale matching completion may
bypass only the first pane debounce sample when the pane also reports waiting.
Pane-only evidence, a different thread, or a newer retained start keeps the
existing debounce behavior. This is the status-only first slice of the design
discussed in #1794; it does not change prompt delivery, queues, or hook
installation.

User impact

A Codex turn with matching completion evidence settles from persisted
running to waiting on the first fresh status poll. An idle-looking pane
without that evidence still cannot manufacture a completion.

Legacy hook JSON remains readable, and older Agent Deck versions ignore the
new additive fields.

Evidence

Clean-main real-Codex reproduction and environment details are recorded in
#1792. For the initial tested branch head, I built commit 7885cfe7 as Agent Deck v1.10.11:

sha256 8b11c7743f9715a9dec64e7809765a6fdbbc00b5c6bb970a58cf11a5e39ffcce

One isolated real-tmux fixture used a scratch HOME/XDG/profile/socket, a fake
Codex prompt, a persisted running row, and a matching completion aged 300s.
The same fixture and same three fresh-process polls produced:

base 580e772c: running, running, running
PR   7885cfe7: waiting, waiting, waiting
persisted DB status after polls: running

Hostile inverse: after retaining a newer start (started_generation=9,
completed_generation=8) while the fake pane still looked idle, three fresh PR
polls remained running.

Hot-path timing over 20 fresh polls on the matching-completion fixture:

base: 0.850s, 0.818s, 0.938s
PR:   0.918s, 0.909s, 1.003s

Tests:

go test ./internal/session -skip TestCanRestartCursor -count=1
ok  github.com/asheshgoplani/agent-deck/internal/session  54.702s

go test ./cmd/agent-deck -count=1
ok  github.com/asheshgoplani/agent-deck/cmd/agent-deck  60.425s

go test -race ./internal/session -run '<focused status tests>' -count=10
ok  github.com/asheshgoplani/agent-deck/internal/session  1.491s

go test -race ./cmd/agent-deck -run TestHandleCodexNotify -count=10
ok  github.com/asheshgoplani/agent-deck/cmd/agent-deck  1.218s

go vet ./...
go build ./...

Automated-review follow-up 77b51588 restores retained main-thread evidence after a rejected subagent hook, recovers safely from corrupt JSON state while preserving I/O/symlink errors, and covers the existing dead-session reset. Its subagent and corruption tests each failed before their production fix. The updated session package passed in 144.288s (excluding the clean-main Cursor failure); the focused cases passed under -race -count=10 in 1.833s; go vet ./... and go build ./... remained green.

A serial sandboxed repository run passed every package except three tests that
fail identically on clean origin/main: TestCanRestartCursor (tmux/Cursor)
and the web keepalive tests TestWSKeepaliveReapsDeadPeer and
TestWSKeepaliveHealthyClientSurvives. The web package passes with those two
clean-main failures excluded. The statedb performance gate, which exceeded its
wall-clock threshold on the host's slow /var filesystem, passes on tmpfs
(insert 6.60 ms under its 630 ms budget; delete 1.96 ms under its 510 ms
budget).

Revert-check: copying the changed tests onto origin/main fails to compile on
the missing hook-state and completion-evidence symbols, which is the expected
failure without the production change.

AI disclosure

  • Human-written
  • AI-assisted (I directed and reviewed it)
  • AI-authored (a model wrote most of it)

Model(s), if AI helped: gpt-5-codex

Prompt / session log (optional):

What actually bothered you

My human asked: “Implement the approved plan test-first, beginning with the
contracts and schema lane specified by the plan.” The plan was triggered by
Codex sessions that had visibly completed but stayed running across every
fresh Agent Deck poll.

Checklist

  • Targeted diff: one problem, no unrelated changes
  • Tests added or updated for new behavior
  • Test suite passes sandboxed: HOME=$(mktemp -d) XDG_CONFIG_HOME= XDG_DATA_HOME= XDG_CACHE_HOME= go test ./... (three failures reproduce on clean origin/main; details above)
  • If this touches a hot path (list, status, session output, startup, tmux layer): before/after timing evidence included
  • CHANGELOG.md untouched (entries are added at landing)
  • AI-assisted? Disclosed above, with validation evidence, and I can answer questions about the code
  • "Allow edits from maintainers" is enabled

Summary by CodeRabbit

  • Bug Fixes

    • Improved session status tracking across turn starts, completions, and empty or delayed hook events.
    • Preserved retained completion evidence across reloads and prevented replacement by subagent completions.
    • Enhanced debounce/“hold/clear” behavior so waiting panes respond correctly when a matching completion is already known.
    • Continued compatibility with legacy saved status data.
  • Reliability & Security

    • Improved persistence robustness by recovering gracefully from corrupted hook status files.
    • Safer, atomic status writes with exclusive per-instance locking.

@Asentient
Asentient requested a review from asheshgoplani as a code owner July 30, 2026 15:49
@github-actions github-actions Bot added intake:clean PR/issue passed the intake contract ai-authored Primarily authored by an AI agent labels Jul 30, 2026
@github-actions

Copy link
Copy Markdown

👋 Thanks for the contribution — intake looks complete.

Your PR body carries everything the maintainer's validation pipeline reads first: the problem, the reasoning, the human intent behind it, and an AI-disclosure. It will be applied, built, and tested against main, and you'll get a structured result within about a day. Merges are always human.

gate marker read: ai=authored model=gpt-5-codex intent=yes

@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The PR introduces shared persisted hook-state documents with retained turn generations, unified status decoding, corrupt-state recovery, and Codex completion-aware status debouncing across fresh CLI polls and live instance updates.

Changes

Codex completion state

Layer / File(s) Summary
Hook state contract and advancement
internal/session/hook_state.go, internal/session/hook_state_test.go
Defines versioned hook-state documents, event kinds, generation tracking, session reset behavior, validation, and legacy JSON compatibility.
Hook state storage and recovery
internal/session/hook_state_store.go, internal/session/hook_state_store_test.go
Adds shared hook-state persistence and continues from an empty document when existing JSON is corrupt.
Hook event integration and decoding
cmd/agent-deck/hook_handler.go, internal/session/hook_watcher.go, internal/session/transition_daemon.go, cmd/agent-deck/codex_hooks_cmd_test.go
Maps hook events into shared state events, preserves done and transcript fields, and centralizes status decoding through HookStateDocument.
Completion-aware status reconciliation
internal/session/instance.go, internal/session/codex_completion_evidence_test.go
Loads retained generations into instances, matches completion evidence to bound sessions, updates debounce behavior, and preserves or clears retained fields during lifecycle updates.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant HookHandler
  participant HookStateStore
  participant SessionInstance
  participant CodexSession
  HookHandler->>HookStateStore: persist turn event
  HookStateStore-->>SessionInstance: expose retained generations
  SessionInstance->>CodexSession: compare completion evidence with bound session
  CodexSession-->>SessionInstance: provide current status observation
  SessionInstance-->>SessionInstance: settle completion-aware debounce
Loading

Possibly related issues

Possibly related PRs

Suggested reviewers: asheshgoplani

🚥 Pre-merge checks | ✅ 4 | ❌ 3

❌ Failed checks (2 warnings, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 16.67% which is insufficient. The required threshold is 70.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Test_coverage_per_surface ⚠️ Warning Coverage is core-only: new retained-completion tests exist in internal/session and cmd, but no web/TUI/remote regression tests exercise the new status path. Add surface-specific regressions for the UpdateStatus/list --json path in web, TUI, and remote, not just hook-state serialization tests.
Remote_parity ❓ Inconclusive placeholder Need repo evidence before deciding.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The changes implement retained completion evidence and generation tracking needed to fix fresh-poll stale running status for #1792.
Out of Scope Changes check ✅ Passed The modifications stay within status handling, persistence, and related tests without introducing unrelated prompt, queue, or delivery changes.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title follows Conventional Commits and accurately describes the PR's status-evidence retention fix.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

@coderabbitai coderabbitai 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.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
internal/session/instance.go (1)

4951-4974: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Codex subagent-rejection path doesn't restore retained completion evidence, corrupting the new matching fields.

Unlike the Claude branch (which restores hookStateSessionID/hookGeneration/hookLastTurn*Generation on both its rejection paths at lines 4878-4881 and 4904-4907), the Codex subagent-rejection branch here returns at line 4972 without restoring prevHookStateSessionID/prevHookGeneration/prevHookStartedGeneration/prevHookCompletedGeneration.

Since these fields are unconditionally overwritten at the top of the function (lines 4818-4821) from the incoming status before the subagent check runs, a completing subagent thread's StateSessionID and turn generations get written into the instance even though its session-id rebind is correctly rejected. This poisons hasMatchingCodexCompletionLocked() (i.hookStateSessionID no longer equals i.CodexSessionID), causing the exact "stuck running/uncertain status" regression issue #1792 sets out to fix — but now for any Codex conductor session that uses subagents, since a subagent's agent-turn-complete arriving after the main thread's genuine completion wipes out the valid retained evidence.

🐛 Proposed fix: restore retained fields on subagent rejection
 		if i.shouldRejectCodexSubagentRebind(sessionID) {
+			i.hookStateSessionID = prevHookStateSessionID
+			i.hookGeneration = prevHookGeneration
+			i.hookLastTurnStartedGeneration = prevHookStartedGeneration
+			i.hookLastTurnCompletedGeneration = prevHookCompletedGeneration
 			_ = WriteSessionIDLifecycleEvent(SessionIDLifecycleEvent{
 				InstanceID: i.ID, Tool: i.Tool, Action: "reject",
 				Source: hookSource, OldID: i.CodexSessionID, Candidate: sessionID,
 				HookEvent: status.Event, Reason: "candidate_is_subagent_thread",
 			})
 			sessionLog.Debug("codex_session_rebind_rejected_subagent",
 				slog.String("old_id", i.CodexSessionID),
 				slog.String("candidate", sessionID),
 				slog.String("event", status.Event),
 			)
 			return
 		}

Want me to also add a regression test in internal/session/codex_completion_evidence_test.go covering a subagent completion arriving after a matching main-thread completion?

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/session/instance.go` around lines 4951 - 4974, Restore the retained
Codex hook evidence before returning from the shouldRejectCodexSubagentRebind
branch: reinstate the prior hook state session ID, hook generation, started
generation, and completed generation values captured before processing status.
Keep the rejection lifecycle event and debug logging unchanged, and ensure
hasMatchingCodexCompletionLocked continues to see the main thread’s retained
completion evidence.
🧹 Nitpick comments (2)
internal/session/hook_state_test.go (1)

50-70: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Add test coverage for the dead event reset branch.

This suite covers the session-ID-mismatch reset (hook_state.go lines 76-81) but not the separate event.Status == "dead" && event.SessionID == "" reset (hook_state.go lines 89-95), which is explicitly called out as a distinct safety-reset case.

✅ Suggested test addition
func TestAdvanceHookStateDeadEventClearsRetainedEvidence(t *testing.T) {
	t.Parallel()

	state := HookStateDocument{
		SchemaVersion:               HookStateSchemaV1,
		Generation:                  5,
		SessionID:                   "thread-1",
		StateSessionID:              "thread-1",
		LastTurnStartedGeneration:   4,
		LastTurnCompletedGeneration: 5,
	}
	next, err := AdvanceHookState(state, HookStateEvent{
		Kind: HookStatusOnly, Status: "dead", Event: "session.dead",
	})
	if err != nil {
		t.Fatalf("AdvanceHookState: %v", err)
	}
	if next.StateSessionID != "" || next.LastTurnStartedGeneration != 0 ||
		next.LastTurnCompletedGeneration != 0 {
		t.Fatalf("dead event did not clear retained evidence: %#v", next)
	}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/session/hook_state_test.go` around lines 50 - 70, Add a focused test
alongside TestAdvanceHookStateNewSessionCannotReusePriorCompletion for a
HookStatusOnly event with Status "dead" and no SessionID. Seed retained session
and turn evidence, call AdvanceHookState, and assert the reset clears
StateSessionID, LastTurnStartedGeneration, and LastTurnCompletedGeneration while
preserving successful error handling.
internal/session/hook_state_store.go (1)

39-46: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Corrupt existing state file permanently blocks future writes.

If the on-disk document fails to decode, writeHookStateAt aborts entirely instead of continuing with a fresh/reset document. This turns a one-time corruption into a permanent stuck state for that instance until manual intervention.

♻️ Suggested fallback on decode failure
 	statePath := filepath.Join(hooksDir, instanceID+".json")
 	previous, err := readHookStateAt(statePath)
-	if err != nil && !os.IsNotExist(err) {
-		return fmt.Errorf("read hook state: %w", err)
-	}
+	if err != nil && !os.IsNotExist(err) {
+		// A corrupt document should not permanently block future updates;
+		// start from a fresh document instead.
+		previous = HookStateDocument{}
+	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/session/hook_state_store.go` around lines 39 - 46, The write flow
around readHookStateAt should distinguish a decode failure from other read
errors and continue with a fresh/reset hook state instead of returning the
error. Preserve the existing not-found handling and still propagate unrelated
I/O errors; then pass the reset state to AdvanceHookState so writeHookStateAt
can recover from corrupt on-disk state.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@internal/session/instance.go`:
- Around line 4951-4974: Restore the retained Codex hook evidence before
returning from the shouldRejectCodexSubagentRebind branch: reinstate the prior
hook state session ID, hook generation, started generation, and completed
generation values captured before processing status. Keep the rejection
lifecycle event and debug logging unchanged, and ensure
hasMatchingCodexCompletionLocked continues to see the main thread’s retained
completion evidence.

---

Nitpick comments:
In `@internal/session/hook_state_store.go`:
- Around line 39-46: The write flow around readHookStateAt should distinguish a
decode failure from other read errors and continue with a fresh/reset hook state
instead of returning the error. Preserve the existing not-found handling and
still propagate unrelated I/O errors; then pass the reset state to
AdvanceHookState so writeHookStateAt can recover from corrupt on-disk state.

In `@internal/session/hook_state_test.go`:
- Around line 50-70: Add a focused test alongside
TestAdvanceHookStateNewSessionCannotReusePriorCompletion for a HookStatusOnly
event with Status "dead" and no SessionID. Seed retained session and turn
evidence, call AdvanceHookState, and assert the reset clears StateSessionID,
LastTurnStartedGeneration, and LastTurnCompletedGeneration while preserving
successful error handling.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: f1180d98-6f64-4898-90a8-bd6992ed0747

📥 Commits

Reviewing files that changed from the base of the PR and between 580e772 and 7885cfe.

📒 Files selected for processing (10)
  • cmd/agent-deck/codex_hooks_cmd_test.go
  • cmd/agent-deck/hook_handler.go
  • internal/session/codex_completion_evidence_test.go
  • internal/session/hook_state.go
  • internal/session/hook_state_store.go
  • internal/session/hook_state_store_test.go
  • internal/session/hook_state_test.go
  • internal/session/hook_watcher.go
  • internal/session/instance.go
  • internal/session/transition_daemon.go

@Asentient

Copy link
Copy Markdown
Author

Addressed all three CodeRabbit findings in 77b51588:

  • Restored the retained main-thread state ID and generations when a Codex subagent rebind is rejected. The new regression test fails on the prior PR head and passes with the fix.
  • Hook-state writes now recover only from JSON decode corruption; unrelated I/O errors and symlink refusal still propagate. Added corruption-recovery coverage and retained the symlink safety test.
  • Added explicit coverage that a session-dead event clears retained evidence.

Validation: focused internal/session package passed (with the clean-main Cursor fixture failure excluded), the new cases passed under -race -count=10, and go vet ./... plus go build ./... passed.

@asheshgoplani asheshgoplani left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Thanks for the fast turnaround on the CodeRabbit findings — the subagent-rebind restoration, the scoped corruption recovery, and the session-dead clear test all check out on 77b5158.

CI is green on this head (full suite, race tests, govulncheck, CodeQL, CodeRabbit checks all passed). Two independent reviews (Claude + Codex) still found gaps in the core goal of this PR (retaining completion evidence across a fresh CLI/daemon process), so holding before merge:

  1. internal/web/session_data_service.go (~285-291, ~351) still decodes the legacy rawHookStatus shape instead of session.HookStateDocument. Every other hook-file reader (hook_watcher.go, transition_daemon.go, hook_handler.go) was migrated, but the web/headless reader wasn't, so it builds a zeroed session.HookStatus{} (empty StateSessionID, generation 0). refreshStatuses then calls UpdateHookStatus, and instance.go (~4818-4822) assigns those fields unconditionally — so each web-surface refresh tick clobbers the retained evidence that StatusFileWatcher just wrote, undoing the fix for any process serving the web/headless surface.

  2. The completion-match gate in codexCompletionMatchesBoundSession / hasMatchingCodexCompletionLocked (instance.go ~4131-4139) has no freshness bound. It's stateSessionID == boundSessionID && completed > 0 && completed >= started, with no check that the completion is the current turn. If a later turn's start event is ever missed (dropped notify, etc.), started stays stale and the bypass stays true indefinitely — so a single transient "waiting" pane read mid-turn skips the debounce and reports a false completion. hookGeneration is already tracked (assigned in several places in instance.go) but never read; gating on completed == generation (or bounding LastTurnCompletedAt) would close this without new plumbing. Codex's own review converged on this same line independently.

  3. Subagent hook writes aren't gated before they hit disk (internal/session/hook_state.go ~76-83). The in-memory guard at instance.go ~4959-4966 stops a subagent event from replacing the bound instance's state in memory, but the on-disk writer path (codex_hooks_cmd.gohook_handler.goWriteHookState) has no equivalent gate, so a subagent notify under the inherited AGENTDECK_INSTANCE_ID can still overwrite the main thread's persisted generations via the session-mismatch reset in hook_state.go. That's the exact cold-process scenario this PR targets, and the new test only covers the in-memory path, not the on-disk one.

Smaller / non-blocking, worth a look before or shortly after merge:

  • instance.go ~4594-4599: the new early-return on matchingCompletion && Status == Waiting also skips the Codex session-ID resync and Claude prompt refresh in the metadata-sync block, not just the DetectTool probes the comment describes.
  • hook_state_store.go: the per-instance .lock file and the new randomized .tmp names aren't covered by the existing stale-file reaper (hook_handler.go cleanStaleHookFiles), so crashed writers now leak files instead of reusing one slot.
  • Two fsyncs were added to the synchronous hook-write path (previously write+rename only) — likely fine given the durability goal, just flagging for a conscious sign-off given prior latency sensitivity there (#1225).

Happy to re-review once (1) and (2) are addressed — those two are what would keep #1792 from actually being fixed on cold processes and the web surface. (3) matters for the same reason but is scoped tighter (subagent-under-inherited-env case).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ai-authored Primarily authored by an AI agent intake:clean PR/issue passed the intake contract

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Codex completion can remain running across fresh CLI polls

2 participants