Skip to content

feat(progress): merge-wait heartbeat with backoff and cold-start status - #12

Merged
aryasaatvik merged 2 commits into
devfrom
feat/progress-heartbeat
Jul 10, 2026
Merged

feat(progress): merge-wait heartbeat with backoff and cold-start status#12
aryasaatvik merged 2 commits into
devfrom
feat/progress-heartbeat

Conversation

@aryasaatvik

@aryasaatvik aryasaatvik commented Jul 10, 2026

Copy link
Copy Markdown
Owner

Problem

Field report (merge-auto-cascade-papercuts-2026-07-10 #6): stack merge --auto was silent for ~4 minutes during cold-start reads and silent while waiting for #N to merge (flat 5s poll, one static line). Liveness had to be checked out-of-band with ps/gh.

Change

  • Wait heartbeat + backoff: CodeHost.wait takes an optional onPoll(elapsedMillis) callback; GitHub and GitLab poll loops back off 5s → 10s → 20s → 30s (cap via new codeHostWaitMaxIntervalMillis, floor unchanged). Stack.landOne emits … waiting for #N to merge (45s) per poll.
  • Cold-start status: two coarse lines per run (reading open changes…, inspecting N branches…) before the expensive read phases in sync/land.
  • stdout stays byte-for-byte identical: ProgressEvent gained an optional stream field; new lines route to stderr via Progress.live. Default-stream events render exactly as before, preserving the repo's outcome-oriented stdout rule.

Seam rationale: the callback keeps GitHub/GitLab adapters decoupled from progress/UI concerns (Stack.ts already owns all progress emission); Memory's narrower wait signature remains structurally compatible.

Notes for review

  • Heartbeats also fire on dry-runs (stderr-only, non-mutating) — one existing zero-events dry-run test was updated to reflect this deliberately.

Validation

bun run typecheck / bun run lint / bun run format:check clean; 141 tests pass (138 on base; +3: GitHub/GitLab backoff+heartbeat via TestClock, land wait-heartbeat elapsed assertions).

@greptile-apps

greptile-apps Bot commented Jul 10, 2026

Copy link
Copy Markdown

Greptile Summary

This PR adds liveness feedback to stack merge --auto and stack sync/land cold-start reads that were previously silent for minutes. All new output routes to stderr, keeping stdout byte-for-byte identical with the existing outcome-oriented contract.

  • Wait heartbeat + backoff: CodeHost.wait gains an optional onPoll(elapsedMillis) callback; GitHub and GitLab poll loops double the sleep interval (5 s → 10 s → 20 s → 30 s cap via codeHostWaitMaxIntervalMillis) and emit … waiting for #N to merge (Xs) per cycle to stderr.
  • Cold-start status lines: reading open changes… and inspecting N branches… fire to stderr before the expensive PR-list and branch-scope phases in both sync and land flows.
  • Test coverage: Three new tests exercise the backoff sequence with TestClock (GitHub and GitLab) and the per-poll heartbeat message format via a makeLand stub; one existing dry-run test is updated to reflect the deliberately emitted cold-start events.

Confidence Score: 5/5

Safe to merge — the change is purely additive (optional callback, optional stream field, new config key with default) and touches no mutation paths.

All interface changes are backward-compatible: onPoll is optional so the Memory adapter and existing callers are unaffected, and the new stream field on ProgressEvent defaults to undefined (stdout) so every existing event renders identically. The backoff arithmetic is simple and verified by TestClock-driven assertions that pin the exact elapsed values at each step. No error-handling paths, auth flows, or data-mutation logic were altered.

No files require special attention.

Important Files Changed

Filename Overview
src/services/CodeHost.ts Extended wait with optional onPoll callback; the existing Memory adapter remains structurally compatible because TypeScript allows callee functions with fewer parameters.
src/services/Config.ts Adds codeHostWaitMaxIntervalMillis (default 30 000 ms) to the config service; defaults are sensible and the new field is optional at construction time.
src/services/Progress.ts Adds optional stream field to ProgressEvent and routes stderr-tagged events through Console.error in the live layer; the noop and memory adapters are unaffected.
src/services/Stack.ts Adds heartbeat, formatElapsed, branchesLabel helpers and emits cold-start status lines plus per-poll elapsed heartbeats; all new lines route to stderr, preserving stdout byte-for-byte.
src/services/code-host/GitHub.ts Backoff loop (5 s → 10 s → 20 s → 30 s cap) with elapsed tracking and onPoll callback is correctly placed after Effect.sleep; logic matches TestClock-driven test assertions.
src/services/code-host/GitLab.ts Mirrors the GitHub backoff implementation exactly; `merged_at
tests/stack.test.ts Three new tests: GitHub and GitLab backoff verified via TestClock with precise elapsed assertions; land auto heartbeat test stubs onPoll via makeLand; dry-run test correctly updated for new stderr-only cold-start events.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant S as Stack.landOne
    participant P as Progress (stderr)
    participant GH as CodeHost.wait (GitHub/GitLab)
    participant API as gh / glab CLI

    S->>P: heartbeat("reading open changes…")
    S->>API: landTarget (fetch + refs + pulls)
    S->>P: heartbeat("inspecting N branches…")
    S->>P: "wait("waiting for #N to merge") [stdout]"
    S->>GH: wait(pr, onPoll)

    loop Poll until merged
        GH->>API: pr view --json state,mergedAt
        API-->>GH: "{state, mergedAt}"
        alt mergedAt set
            GH-->>S: return (done)
        else "state != OPEN"
            GH-->>S: fail (closed without merging)
        else still OPEN
            GH->>GH: sleep(interval)
            GH->>GH: "elapsed += interval"
            GH->>S: onPoll(elapsed)
            S->>P: "heartbeat("waiting for #N to merge (Xs)") [stderr]"
            GH->>GH: "interval = min(interval×2, maxInterval)"
        end
    end
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 S as Stack.landOne
    participant P as Progress (stderr)
    participant GH as CodeHost.wait (GitHub/GitLab)
    participant API as gh / glab CLI

    S->>P: heartbeat("reading open changes…")
    S->>API: landTarget (fetch + refs + pulls)
    S->>P: heartbeat("inspecting N branches…")
    S->>P: "wait("waiting for #N to merge") [stdout]"
    S->>GH: wait(pr, onPoll)

    loop Poll until merged
        GH->>API: pr view --json state,mergedAt
        API-->>GH: "{state, mergedAt}"
        alt mergedAt set
            GH-->>S: return (done)
        else "state != OPEN"
            GH-->>S: fail (closed without merging)
        else still OPEN
            GH->>GH: sleep(interval)
            GH->>GH: "elapsed += interval"
            GH->>S: onPoll(elapsed)
            S->>P: "heartbeat("waiting for #N to merge (Xs)") [stderr]"
            GH->>GH: "interval = min(interval×2, maxInterval)"
        end
    end
Loading

Reviews (2): Last reviewed commit: "feat(progress): cold-start read-phase st..." | Re-trigger Greptile

merge --auto could go silent for minutes while polling gh/glab for a
merge. Back the poll interval off geometrically (5s floor, doubling to
a 30s cap) and emit an elapsed-time heartbeat on every poll so the
CLI stays visibly alive.

CodeHost.wait now takes an optional onPoll callback instead of taking
a Progress dependency directly, keeping the GitHub/GitLab adapters
decoupled from progress rendering; Stack.ts (which already owns
step/wait progress emission) wires the callback to the existing Wait
progress event, tagged to render on stderr so stdout output is
unchanged.
sync and merge could go silent for minutes on cold-start PR-list and
per-branch reads with no indication anything was happening. Emit a
couple of coarse, stderr-only status lines ("reading open changes…",
"inspecting N branches…") before those reads in Stack.ts's sync/land
paths. Stdout output, exit codes, and mutation behavior are unchanged;
a bare `stack sync` stays non-mutating.

Adds a changeset for the wait-heartbeat + cold-start progress work.
@aryasaatvik
aryasaatvik force-pushed the feat/progress-heartbeat branch from 828ccf5 to 22fa16c Compare July 10, 2026 07:00
@aryasaatvik
aryasaatvik merged commit de442cb into dev Jul 10, 2026
3 checks passed
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