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:
Complexity
L
Suggested agent
developer-workflow:swift-engineer
Module / Layer
GitHubIntegrationFeature / Effects (polling)
Description
Extend
GitHubFeature.swiftwith the polling coordinator — timers, TaskGroup, ETag round-trip, pause/resume, rate-limit backoff, SAML quarantine.(1) New actions
(2) Polling logic
.signInResult(.success)start batch timer (60s + jitter 0–3s →.pollTick(.batch)) and selected timer (15s + jitter 0–2s →.pollTick(.selected)). Cancellable byPollingCancelID.batch/PollingCancelID.selected..pollTick(.batch)→ for each non-quarantined github worktree inperBranch,TaskGroupparallel:client.getCombinedStatus(ref: sha, etag: previousETag)+client.listWorkflowRuns(branch, etag: previousETag). Emit.fetchResultper result..pollTick(.selected)→ only forstate.selectedWorktreePath..pollTick(.manual(path))→ immediate, for path (or selected if nil)..fetchResult(_, _, .success(newStatus, newETag)):combinedStatus = .fresh(newStatus), save ETag,lastFetchedAt = now.stale(existing, age)ifage > 60s;lastFetchedAt = now.fetchResult(_, _, .failure(.rateLimited(resetAt)))→ cancel both timers; schedule.rateLimitBackoffExpiredatresetAt + 5s; allperBranch[_].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:
.fetchResultper worktree.fresh; second fetch 304 →.stale(existing, age)with correct age.rateLimited(resetAt)→ timers cancelled;.rateLimitBackoffExpiredscheduled; restart on expiry.forbidden(.saml)for pathA → pathA quarantined; subsequent.pollTickskips pathA; pathB continues.windowActiveChanged(false)→ no ticks;trueresumes.remotesUpdated→ no more fetches for that path.signOutTappedComplexity
L
Suggested agent
developer-workflow:swift-engineerModule / Layer
GitHubIntegrationFeature/ Effects (polling)