Skip to content

fix: restore run TUI elapsed timer across retry, failure, cancel, and remote paths - #221

Merged
compozybot merged 4 commits into
compozy:mainfrom
MatheusBBarni:fix/tui-retry-success-timer
Jul 3, 2026
Merged

compozybot merged 4 commits into
compozy:mainfrom
MatheusBBarni:fix/tui-retry-success-timer

Conversation

@MatheusBBarni

@MatheusBBarni MatheusBBarni commented Jul 2, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Fixes the run TUI bug where the elapsed timer disappears after a retried job succeeds — and completes the fix so the timer is correct for every terminal outcome (success, failure, cancel) and for remote tabs that attach after a job already finished.

Root cause: the UI derived a job's elapsed time from a locally-tracked startedAt that the retry flow never seeds (retry attempts emit no fresh JobStarted, and a remote tab can bootstrap a job mid-retry), while discarding the authoritative duration the executor already computes.

Screenshot 2026-07-01 at 13 42 19

What changed

Core fix — thread the executor's authoritative JobCompletedPayload.DurationMs into the UI:

  • jobFinishedMsg gains DurationMs; translateJobCompletedEvent carries it; handleJobFinished prefers it and backfills startedAt for triple-coherence.

Completion across all terminal outcomes — no persistence/schema migration required:

  • Part A (live failure/cancel): DurationMs added to JobFailedPayload/JobCancelledPayload, populated in executor/lifecycle.go via a zero-guarded elapsedMs() (guard matters: markGiveUp/markCanceled can fire before any attempt starts), threaded through the failure and cancel UI translators.
  • Part B (remote pre-attach): DurationMs added to the RunJobSummary contract; the daemon snapshot builder populates it from the authoritative payload with a JobStarted → terminal timestamp fallback for historical journals; remote.go threads it into all three terminal bootstrap arms.

Why this altitude

Duration was only carried on the live JobCompleted event. Generalizing it to all terminal payloads + the remote summary contract fixes the whole class (blank timer on retry→failure/cancel; ~00:00 on remote pre-attach) instead of special-casing the success arm.

Testing

  • gofmt -s: clean · go build ./...: OK · golangci-lint (5 packages): 0 issues
  • go test -race: pkg/compozy/events/kinds, internal/core/run/ui, internal/core/run/executor, internal/daemon, internal/api/contract — all pass
  • New tests: executor terminal-duration (failed/canceled/zero-guard), UI translator threading (failure + cancel), remote bootstrap threading (all 3 arms), daemon resolveDurationMs (payload-wins / timestamp-fallback / no-start), plus a translator guard and reproduction test in the core fix.

Summary by CodeRabbit

  • New Features

    • Terminal job events (completed, failed, cancelled) now carry an elapsed duration value end-to-end.
    • Job timing is consistently shown in the UI, including for retries and remote-attach/snapshot restores.
  • Bug Fixes

    • Fixed missing/incorrect duration values in terminal event handling and when rebuilding run snapshots.
    • Timer rendering is improved so the job timer reflects the authoritative duration instead of staying blank.
  • Tests

    • Added/extended coverage to verify duration propagation through terminal, retry, and remote/bootstrap flows.

MatheusBBarni and others added 2 commits July 1, 2026 16:36
Thread the executor's authoritative JobCompletedPayload.DurationMs through
jobFinishedMsg into handleJobFinished, which previously derived elapsed time
from a startedAt that the retry flow never seeds (retry attempts emit no
JobStarted; a remote tab can bootstrap a job mid-retry). The sidebar timer no
longer stays blank after a retried job completes.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
…e jobs

Complete the retry-timer fix across every terminal outcome. Add DurationMs to
JobFailedPayload/JobCancelledPayload and populate it in the executor
(markGiveUp/markCanceled, zero-guarded when no attempt started), thread it
through the failure and cancel UI translators, and add DurationMs to the
RunJobSummary contract populated by the daemon snapshot builder (authoritative
payload value with a JobStarted->terminal timestamp fallback for historical
journals). The remote bootstrap threads it into every terminal arm, so a tab
attaching to a job that finished before attach shows the real elapsed time
instead of a bogus ~00:00. No persistence/schema migration required.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@coderabbitai

coderabbitai Bot commented Jul 2, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: a5fc6edf-4d8d-4a25-b356-be8675d7677d

📥 Commits

Reviewing files that changed from the base of the PR and between 62b7388 and 12a04f5.

📒 Files selected for processing (1)
  • internal/core/run/ui/adapter_test.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • internal/core/run/ui/adapter_test.go

Walkthrough

This PR adds DurationMs to job payloads and the run job summary contract, computes duration in the executor, threads it through UI translation and rendering, and persists it in daemon run snapshots, with tests covering each path.

Changes

Job Duration Threading

Layer / File(s) Summary
Payload and contract schema additions
pkg/compozy/events/kinds/job.go, internal/api/contract/types.go
Adds DurationMs to job failure and cancellation payloads and to RunJobSummary.
Job lifecycle elapsed duration computation
internal/core/run/executor/lifecycle.go, internal/core/run/executor/execution_acp_test.go
Computes terminal-event duration in the executor and verifies failed, cancelled, and unset-start cases.
UI event translation carries DurationMs
internal/core/run/ui/types.go, internal/core/run/ui/model.go, internal/core/run/ui/adapter_test.go
Adds DurationMs to jobFinishedMsg and populates it for completed, failed, and cancelled event translation paths.
UI job duration rendering and remote bootstrap
internal/core/run/ui/update.go, internal/core/run/ui/remote.go, internal/core/run/ui/remote_test.go, internal/core/run/ui/update_test.go
Uses authoritative duration in UI completion handling and remote bootstrap, with tests for hydration and timer restoration.
Daemon run snapshot duration resolution
internal/daemon/run_snapshot.go, internal/daemon/run_snapshot_test.go
Derives and stores DurationMs in run snapshots with payload and timestamp fallback behavior.

Estimated code review effort: 3 (Moderate) | ~25 minutes

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: restoring the run TUI elapsed timer across retry, terminal, and remote attach paths.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ 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.

@greptile-apps

greptile-apps Bot commented Jul 2, 2026 •

Copy link
Copy Markdown

Greptile Summary

This PR fixes a TUI bug where the elapsed timer disappeared after a retried job succeeded and extends that fix to all terminal outcomes (success, failure, cancel) and remote-attach scenarios. The root cause was that the UI derived job elapsed time from a locally-tracked startedAt that was never seeded on retry paths, while the authoritative duration computed by the executor was discarded.

  • Core fix: jobFinishedMsg gains DurationMs; handleJobFinished now prefers the executor's authoritative duration and backfills startedAt for triple coherence, eliminating the blank timer on retry→success.
  • Failure/cancel paths: DurationMs added to JobFailedPayload and JobCancelledPayload, populated via a zero-guarded elapsedMs() (the guard handles termination before any attempt starts), threaded through the translators.
  • Remote pre-attach: DurationMs added to RunJobSummary; the daemon snapshot builder populates it with a payload-wins/timestamp-fallback strategy for historical journals; remote.go threads it through all three terminal bootstrap arms.

Confidence Score: 5/5

Safe to merge — the change is additive (new fields with omitempty), covers all terminal paths, and is protected by comprehensive new tests including race-enabled runs.

The fix is narrow and targeted: new fields are additive and JSON-tagged omitempty so old clients are unaffected. The zero-guard in elapsedMs() correctly handles the pre-attempt case. The handleJobFinished preference logic and startedAt backfill are coherent. The daemon snapshot builder's payload-wins/timestamp-fallback strategy gracefully handles historical journals. Tests cover every new code path including the reproduction scenario.

No files require special attention.

Important Files Changed

Filename Overview
internal/core/run/executor/lifecycle.go Adds zero-guarded elapsedMs() helper and populates DurationMs on JobFailedPayload and JobCancelledPayload, mirroring the existing markSuccess pattern.
internal/core/run/ui/update.go Core UI fix: handleJobFinished now prefers authoritative DurationMs and backfills startedAt for coherence when it was never seeded by a retry path.
internal/daemon/run_snapshot.go Adds startedAt to runSnapshotJob and resolveDurationMs with payload-wins/timestamp-fallback for historical journals; all three terminal apply methods updated.
internal/core/run/ui/remote.go Adds summaryDurationMs nil-safe helper and threads it through all three terminal bootstrap arms (completed, failed, canceled).
pkg/compozy/events/kinds/job.go Extends JobFailedPayload and JobCancelledPayload with DurationMs int64, matching the existing field on JobCompletedPayload.
internal/api/contract/types.go Adds DurationMs int64 to RunJobSummary for the remote-attach snapshot contract.
internal/core/run/ui/model.go Threads DurationMs from payloads into jobFinishedMsg across the failure, completed, and cancelled translators.
internal/core/run/ui/types.go Adds DurationMs int64 to jobFinishedMsg to carry authoritative duration from executor to UI handler.
internal/core/run/executor/execution_acp_test.go New tests cover failed/canceled/pre-attempt zero-guard terminal duration paths; subtests use t.Parallel() correctly.
internal/core/run/ui/update_test.go Adds reproduction test for the retry→success timer restoration scenario; verifies sidebarTimeString renders the authoritative duration.
internal/core/run/ui/adapter_test.go Adds translator threading tests for completed and cancelled events, and extends the existing failure test with a DurationMs assertion.
internal/core/run/ui/remote_test.go Adds test verifying all three terminal bootstrap arms thread DurationMs; extends existing snapshot test with duration assertion.
internal/daemon/run_snapshot_test.go New TestRunSnapshotJobResolveDurationMs covers payload-wins, timestamp-fallback, and no-start cases; extends existing lifecycle test with a duration assertion.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant E as Executor (lifecycle.go)
    participant J as Journal / Event Bus
    participant T as UI Translator (model.go)
    participant H as handleJobFinished (update.go)
    participant S as Sidebar Timer

    Note over E: markGiveUp / markCanceled / markSuccess
    E->>E: elapsedMs() → DurationMs
    E->>J: "emit JobFailed/Cancelled/Completed {DurationMs}"
    J->>T: translateMessages / translateJobCompletedEvent
    T->>H: "jobFinishedMsg{DurationMs}"
    H->>H: "if DurationMs>0 → job.duration = DurationMs*ms"
    H->>H: backfill startedAt if zero
    H->>S: render duration

    Note over J,S: Remote pre-attach path
    J->>J: "RunSnapshotBuilder.applyJob*"
    J->>J: resolveDurationMs(payload vs timestamp)
    J->>J: RunJobSummary.DurationMs set
    J->>T: "remoteBootstrapTerminalMsgs{summaryDurationMs}"
    T->>H: "jobFinishedMsg{DurationMs}"
    H->>S: render duration
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant E as Executor (lifecycle.go)
    participant J as Journal / Event Bus
    participant T as UI Translator (model.go)
    participant H as handleJobFinished (update.go)
    participant S as Sidebar Timer

    Note over E: markGiveUp / markCanceled / markSuccess
    E->>E: elapsedMs() → DurationMs
    E->>J: "emit JobFailed/Cancelled/Completed {DurationMs}"
    J->>T: translateMessages / translateJobCompletedEvent
    T->>H: "jobFinishedMsg{DurationMs}"
    H->>H: "if DurationMs>0 → job.duration = DurationMs*ms"
    H->>H: backfill startedAt if zero
    H->>S: render duration

    Note over J,S: Remote pre-attach path
    J->>J: "RunSnapshotBuilder.applyJob*"
    J->>J: resolveDurationMs(payload vs timestamp)
    J->>J: RunJobSummary.DurationMs set
    J->>T: "remoteBootstrapTerminalMsgs{summaryDurationMs}"
    T->>H: "jobFinishedMsg{DurationMs}"
    H->>S: render duration
Loading

Reviews (3): Last reviewed commit: "test: wrap translator duration tests in ..." | Re-trigger Greptile

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

Actionable comments posted: 2

🧹 Nitpick comments (1)
internal/daemon/run_snapshot_test.go (1)

14-39: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Align new subtest names with the file's "Should..." convention.

Sibling subtests in this same function (e.g., "Should clone run job summary code files") follow a "Should..." naming convention, but the new resolveDurationMs subtests use plain descriptions instead.

♻️ Proposed rename for consistency
-	t.Run("prefers the authoritative payload duration", func(t *testing.T) {
+	t.Run("Should prefer the authoritative payload duration", func(t *testing.T) {
 		t.Parallel()
 		job := &runSnapshotJob{startedAt: base}
 		if got := job.resolveDurationMs(90_000, base.Add(2*time.Second)); got != 90_000 {
 			t.Fatalf("want payload duration 90000, got %d", got)
 		}
 	})
-	t.Run("falls back to the started->terminal timestamp span", func(t *testing.T) {
+	t.Run("Should fall back to the started->terminal timestamp span", func(t *testing.T) {
 		t.Parallel()
 		job := &runSnapshotJob{startedAt: base}
 		if got := job.resolveDurationMs(0, base.Add(3*time.Second)); got != 3000 {
 			t.Fatalf("want fallback duration 3000, got %d", got)
 		}
 	})
-	t.Run("reports zero when the job never started", func(t *testing.T) {
+	t.Run("Should report zero when the job never started", func(t *testing.T) {
 		t.Parallel()
 		job := &runSnapshotJob{}
 		if got := job.resolveDurationMs(0, base.Add(3*time.Second)); got != 0 {
 			t.Fatalf("want zero duration without a start, got %d", got)
 		}
 	})

As per path instructions, "MUST use t.Run("Should...") pattern for ALL test cases".

🤖 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/daemon/run_snapshot_test.go` around lines 14 - 39, The new subtests
in TestRunSnapshotJobResolveDurationMs do not follow the required “Should...”
naming convention used elsewhere in this test file. Rename each t.Run case in
TestRunSnapshotJobResolveDurationMs to start with “Should...” so it matches the
existing pattern and the path instructions, while keeping the same coverage for
resolveDurationMs behavior.

Source: Path instructions

🤖 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.

Inline comments:
In `@internal/core/run/executor/execution_acp_test.go`:
- Around line 1454-1515: Rename each subtest in
TestJobLifecycleTerminalEventsCarryDuration to follow the required
t.Run("Should...") convention, and add t.Parallel() at the start of each
independent subtest. Keep the existing assertions and setup in the new subtests,
and apply the same pattern consistently across the lifecycle scenarios so the
test names and execution model match the project guidelines.

In `@internal/core/run/ui/adapter_test.go`:
- Around line 44-70: The new duration translation tests are written as
standalone test functions instead of the required subtest format. Update
TestTranslateEventJobCompletedThreadsDuration and the matching
TestTranslateEventJobCancelledThreadsDuration to be wrapped in t.Run with a
"Should..." subtest name, keeping the existing assertions inside the subtest
body. Make the change in the translateEventForTest coverage so all new cases
follow the repo’s test naming convention.

---

Nitpick comments:
In `@internal/daemon/run_snapshot_test.go`:
- Around line 14-39: The new subtests in TestRunSnapshotJobResolveDurationMs do
not follow the required “Should...” naming convention used elsewhere in this
test file. Rename each t.Run case in TestRunSnapshotJobResolveDurationMs to
start with “Should...” so it matches the existing pattern and the path
instructions, while keeping the same coverage for resolveDurationMs behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 7c494b1d-cb1e-4d66-bbfb-019953abe654

📥 Commits

Reviewing files that changed from the base of the PR and between b4dc542 and 4cbce91.

📒 Files selected for processing (13)
  • internal/api/contract/types.go
  • internal/core/run/executor/execution_acp_test.go
  • internal/core/run/executor/lifecycle.go
  • internal/core/run/ui/adapter_test.go
  • internal/core/run/ui/model.go
  • internal/core/run/ui/remote.go
  • internal/core/run/ui/remote_test.go
  • internal/core/run/ui/types.go
  • internal/core/run/ui/update.go
  • internal/core/run/ui/update_test.go
  • internal/daemon/run_snapshot.go
  • internal/daemon/run_snapshot_test.go
  • pkg/compozy/events/kinds/job.go

Comment thread internal/core/run/executor/execution_acp_test.go
Comment thread internal/core/run/ui/adapter_test.go
Addresses PR review: rename the TestJobLifecycleTerminalEventsCarryDuration and
TestRunSnapshotJobResolveDurationMs subtests to the repo's Should-prefixed
convention and mark the independent lifecycle subtests t.Parallel().

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
coderabbitai[bot]
coderabbitai Bot previously approved these changes Jul 2, 2026
Addresses the remaining CodeRabbit review comment: the two
TestTranslateEvent*ThreadsDuration cases now run inside a Should-prefixed
t.Run subtest, matching the ui package's dominant convention.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
@compozybot
compozybot merged commit aaecbc2 into compozy:main Jul 3, 2026
8 checks passed
pedronauck added a commit that referenced this pull request Jul 3, 2026
Add human-written release notes for the next release covering the work
merged since v0.2.10:

- feat: dependency-aware parallel task execution (--parallel-tasks) (#212)
- feat: agentic recovery for failed runs (--recovery) (#212)
- feat: COMPOZY_HOME for isolated per-project daemons (#216)
- feat: repo-level default overrides in compozy setup (#90)
- fix: safer worktree management for parallel runs (#217, #223)
- fix: ACP runs consistently apply the selected model (#215)
- fix: run TUI elapsed timer restored across all terminal outcomes (#221)

Co-Authored-By: Claude Fable 5 <[email protected]>
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.

2 participants