Skip to content

T-8b: Polling coordinator — timers, TaskGroup, ETag, pause, backoff #225

@kirich1409

Description

@kirich1409

Description

Extend GitHubFeature.swift with the polling coordinator — timers, TaskGroup, ETag round-trip, pause/resume, rate-limit backoff, SAML quarantine.

(1) New actions

public enum PollKind: Sendable {
    case batch
    case selected
    case manual(URL?)
}
public enum FetchKind: Sendable { case combinedStatus, workflowRuns }

// Additions to GitHubFeature.Action
case pollTick(PollKind)
case fetchResult(worktreePath: URL, kind: FetchKind, Result<FetchPayload, GitHubAPIError>)
case windowActiveChanged(Bool)
case rateLimitBackoffExpired

(2) Polling logic

  • On .signInResult(.success) start batch timer (60s + jitter 0–3s → .pollTick(.batch)) and selected timer (15s + jitter 0–2s → .pollTick(.selected)). Cancellable by PollingCancelID.batch / PollingCancelID.selected.
  • .pollTick(.batch) → for each non-quarantined github worktree in perBranch, TaskGroup parallel: client.getCombinedStatus(ref: sha, etag: previousETag) + client.listWorkflowRuns(branch, etag: previousETag). Emit .fetchResult per result.
  • .pollTick(.selected) → only for state.selectedWorktreePath.
  • .pollTick(.manual(path)) → immediate, for path (or selected if nil).
  • .fetchResult(_, _, .success(newStatus, newETag)):
    • if 200: combinedStatus = .fresh(newStatus), save ETag, lastFetchedAt = now
    • if 304: keep existing; transition to .stale(existing, age) if age > 60s; lastFetchedAt = now
  • .fetchResult(_, _, .failure(.rateLimited(resetAt))) → cancel both timers; schedule .rateLimitBackoffExpired at resetAt + 5s; all perBranch[_].combinedStatus = .error(err) during backoff.
  • .rateLimitBackoffExpired → restart both timers.
  • .fetchResult(path, _, .failure(.forbidden(.saml)))perBranch[path].combinedStatus = .error(err), perBranch[path].quarantined = true — excluded until sign-out+sign-in.
  • .fetchResult(_, _, .failure(other))combinedStatus = .error(err); retry next tick.
  • .windowActiveChanged(false) → cancel both timers; ETags preserved.
  • .windowActiveChanged(true) & signed in → restart (next tick usually 304 via preserved ETags).
  • .signOutTapped (extending T-8a) → cancel all timers.

Jitter per tick. Use TCA @Dependency(\.continuousClock) for testable timing.

(3) ETag cache

Stored in BranchGitHubState.etags. Eviction handled by T-8a .remotesUpdated.

Spec reference

See swarm-report/github-integration-decomposition.md#t-8b (split from original T-8 per BA critical-1).

Relationships

Acceptance criteria

TestStore tests with mock clock + mock client:

  • Sign in → batch tick fires after 60s + jitter → .fetchResult per worktree
  • ETag round-trip: 200 → .fresh; second fetch 304 → .stale(existing, age) with correct age
  • .rateLimited(resetAt) → timers cancelled; .rateLimitBackoffExpired scheduled; restart on expiry
  • .forbidden(.saml) for pathA → pathA quarantined; subsequent .pollTick skips pathA; pathB continues
  • .windowActiveChanged(false) → no ticks; true resumes
  • Two worktrees in parallel → two concurrent tasks in TaskGroup, independent results
  • Worktree removed via .remotesUpdated → no more fetches for that path
  • No leaks — cancellables released on .signOutTapped
  • Swift 6 strict concurrency clean

Complexity

L

Suggested agent

developer-workflow:swift-engineer

Module / Layer

GitHubIntegrationFeature / Effects (polling)

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions