Skip to content

Sync a worktree without a coding session by running the git sync in Treemon - #154

Merged
0101 merged 50 commits into
mainfrom
auto-update-updates
Jul 31, 2026
Merged

Sync a worktree without a coding session by running the git sync in Treemon#154
0101 merged 50 commits into
mainfrom
auto-update-updates

Conversation

@0101

@0101 0101 commented Jul 28, 2026

Copy link
Copy Markdown
Owner

Problem

Auto-sync only worked when a coding session was open. The card toggle marked a branch for syncing, but the actual work — fetch, merge, push — was delegated to an agent in a live session. A worktree parked overnight with the toggle on stayed behind base until someone opened a session, which is exactly when nobody is watching. docs/spec/worktree-monitor.md recorded this as a deliberate decision ("agent-driven auto-sync preference rather than a mechanical Git pipeline"), so the gap was by design rather than an oversight.

Prompt delivery had no memory either: deduplication lived only in process state, so a restart re-prompted work already queued to an agent.

Changes

Mechanical sync when no session is open (GitBranchSync.fs, new)

Treemon now does the Git work itself and spends an agent only on what it could not finish. The sequence is deliberately narrow: merge only a worktree proven clean (a content probe that cannot answer refuses too), fetch {upstreamRemote}/{baseBranch}, try a fast-forward and then a non-editing merge, abort conflicts, and verify the fetched base is an ancestor of HEAD. The branch is re-read from the worktree immediately before each mutation, so a checkout landing mid-operation refuses instead of merging into a branch nobody observed. No project builds or tests run on this path.

A successful sync is published only when the branch has an open pull request, as a non-force push with both halves of the refspec spelled out, so neither push.default nor HEAD picks what moves. The push refuses an unconfigured upstream, an option-like remote name, an upstream naming a different branch, and a remote that has moved ahead. Every refusal, conflict, and command failure falls back to one agent prompt carrying only a closed structured reason — never raw Git output, paths, or commit text.

An open session still owns its worktree and is only asked to sync, unchanged.

Restart-safe deduplication (AutoSyncStore.fs, new)

An accepted prompt — and only an accepted one — persists the worktree path, base revision, and acceptance time to port-scoped gitignored runtime state. The same revision stays suppressed for an hour, long enough to cover an in-progress sync; a different revision triggers immediately. A missing or corrupt record loads as empty, so an unusable record re-prompts rather than silently suppressing. Catching up, disabling the preference, and deleting the worktree all clear it.

PR status the push decision can rely on

PrInfo now carries a three-state PrState (Open | Merged | ClosedUnmerged) instead of independent IsOpen/IsMerged booleans, because a merged flag alone cannot tell a closed-unmerged pull request from an open one. GitHub matches pull requests by bare head ref, so a fetched PR counts only when its head repository is one the checkout could push to — the configured remotes' push-URL owners plus the upstream owner. That keeps fork workflows working while stopping an outsider's fork PR from deciding a same-named local branch's push gate. A repository whose PR data has never loaded is treated as unknown rather than "no pull request", so a sessionless sync defers to the next refresh instead of merging locally and recording an acceptance whose push it could never make.

Extension send queue (send-queue.mjs, new)

The serialized session.send queue and its pending-duplicate coalescing moved out of extension.mjs into its own module with tests. A payload stops being pending the moment it is handed to session.send, so suppression only ever removes a message that would repeat an undelivered one.

Module split

RefreshScheduler.fs had grown past the repository's file-size limit, so the DashboardState/StateMsg/processMessage state slice moved to SchedulerState.fs. Behaviour-preserving.

Tests

Server, client, and test projects build clean (0 warnings). Fast suite: 1773 passed, 0 failed.

New coverage: BranchSyncTests.fs (merge, abort, dirty refusal, branch-change refusal, push targeting and refusals), AutoSyncStoreTests.fs (durability, corruption, retry window), plus GitHub fixtures for fork head owners and branch-name reuse.

0101 added 30 commits July 27, 2026 16:31
…merged PR is never masked

Preserve provider fetch order when deduplicating GitHub PRs by branch, add reused-branch fixture coverage, and checkpoint related specification updates.
Coalesce exact duplicate pending prompts in the server bridge and extension send queue while preserving later deliveries.
Add the persistent accepted-sync store, runtime wiring, tests, and spec updates.
…y-accepted revision

Persist accepted sync revisions, suppress same-revision prompts during the retry window, and clear stale records at lifecycle boundaries.
…merged

Disable merged branches during refresh, reject re-enable attempts, and cover eligibility races and cleanup with tests.
Add bounded branch sync outcomes, safe merge/abort verification, and scratch-repository tests.
…enness is explicit

Introduce an explicit sync target DU, thread it through auto-sync delivery and scheduler dependencies, and update selection tests.
Add provider-routed live open-PR queries, response classification, and focused fixtures and tests.
Document that live GitHub detection must include pull requests originating from forks.
Add a bounded non-force current-branch push helper and scratch-repository coverage, and update the Branch Sync specification.
…e requeue

Reuse pending-envelope coalescing when drain survivors merge with concurrent enqueues, with regression coverage and spec clarification.
Syncing onto main brought in the rename of hasLocalDiff to
localComparisonContent and its widening from �ool to the tri-state
ComparisonContent. syncWithBase was written against the old boolean, where a
failed probe read as "not dirty" and let the merge proceed.

Under the new type a probe that could not answer is Undetermined, not Clean,
so the sync now refuses it as a command failure and refuses confirmed local
content as dirty. Only a worktree proven clean is merged, which is what the spec
already required of the mechanical path.

The scheduler test fixtures pick up the same rename and the auto-sync store the
branch added to SchedulerServices.
…e same server process

Track claim state separately from delivery and retry accepted revisions after expiry without restarting.
…uring toggle

Re-read PR status after enabling auto-sync, roll back merged branches, and add race coverage plus spec clarification.
…branch lookup

The GitHub open-PR query filtered by `{upstreamOwner}:{branch}`, so a pull request whose head branch lives in a fork answered an empty list - a confirmed absence that would finish a mechanical sync without pushing. The head owner now comes from the remote git records for that branch, the same remote the push sends to; an unresolvable or non-GitHub head remote leaves the state unknown.
Document that fork head ownership must come from the branch remote's effective push URL, including remotes with a distinct pushurl.
…urable records

Publish accepted records before retrying expired claims, clear synchronized claim state after catch-up, and add concurrent-expiry regression coverage.
Document that a stale catch-up clear must not erase or retire a later accepted auto-sync generation.
…ceptance

Give every acceptance a generation that both the durable record and the in-process claim carry, so a catch-up retires only the acceptance it read and can neither erase a later acceptance's record nor retire its claim.
…c race test

Replace the two ref cells with method-scoped mutable locals and document the mailbox-loop impurity boundary without changing the deterministic interleaving or assertions.
… for GitHub head ownership

Resolve GitHub PR head ownership from the branch remote's effective push URL and cover fork, unreadable, non-GitHub, and argument-list cases.
…n, prompt an agent only on failure

Run guarded mechanical branch sync for worktrees without open sessions and fall back to an agent prompt only when the operation cannot complete.
Document that the live PR lookup and push must stay bound to the branch observed and mechanically synchronized.
…ne branch

Guard mechanical sync and non-force push against checked-out branch changes, with scratch-repository coverage and updated fallback behavior.
…d branch

Re-read the checked-out branch inside the Git sync itself, immediately before each merge command, so a checkout landing during the cleanliness probe or the fetch can no longer redirect the merge onto another branch. Cover the window with scratch-repository regression tests.
Reject configured push remotes beginning with a dash before building the Git push command, with focused tests and spec clarification.
…n open PR

Match the GitHub PR head owner and branch, document the behavior, and add regression coverage.
…til its operation ends

Keep active operation guards until AutoSync.trigger releases them, and add regression coverage for disappearing worktrees.
…t its edge

Extract pure open-PR response classification and keep logging at the response boundary.
… branch bloated

Move branch synchronization into GitBranchSync and scheduler state into SchedulerState, then requalify consumers and update architecture documentation.
0101 and others added 3 commits July 28, 2026 16:39
Removes machinery this branch added that duplicates work already done
elsewhere, with no change to observable sync behavior.

Deduplication now rests on the durable accepted-revision record alone.
AutoSync.trigger holds the per-worktree operation guard across an entire
attempt, so two operations for one worktree cannot overlap; the in-process
revision claim it was paired with (ClaimedRevision, ClaimReason,
AcceptanceGeneration, AutoSyncTriggeredRevisions and its five StateMsg
cases) only re-solved that same problem.

The push decision reads PrInfo.IsOpen instead of a second provider query.
The PR refresh already fetches open and closed pull requests separately and
then discarded which was which, so carrying that through the reconciled map
replaces PrOpenState, both queryOpenPrState implementations, and the fork
head-owner resolution. A push decided from data one refresh old is
self-correcting: it is non-force, and the next cycle catches up.

Also removed: server-side pending-prompt coalescing (the extension queue
covers every producer), merged-branch auto-disable and the toggle's
write-then-reread-then-rollback (eligibility already refuses merged
branches at every action boundary), two of five rev-parse HEAD probes (the
merge and the push each re-read the branch themselves), durable-record
pruning on worktree disappearance (records are revision-keyed and expire),
and ~200 lines of rationale-essay comments.

Net -1,944 lines: -577 production, -1,367 tests. Specs updated to describe
the current system.

Copilot-Session: 060af155-a294-49e5-88a8-c457e926131c
Resolved three conflicts and the breakage the merge caused:

- GithubPrStatus.ParsedGithubPr: kept both new fields — this branch's IsOpen
  (open-PR classification for the auto-sync push gate) and main's
  AutoMergeEnabled (#153 auto-merge badge).
- CliTests FormatPrTests: took main's basePr copy-and-update fixture, which the
  merged test bodies already call, and added IsOpen.
- code-improvements.md: kept this branch's updated row 8 (state slice extracted
  to SchedulerState.fs) and honored main's deletion of row 9, whose ProcessRunner
  consolidation shipped in #150.
- GitBranchSync: ported to main's Spawn API — runArgumentListWithTimeout is gone;
  CaptureLimits.small is the same 64 KB bound, Timeout keeps the 120 s budget.
- AutoSyncTests/BranchSyncTests: PrInfo literals set AutoMergeEnabled.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 80f775f5-e421-45b9-acac-d4f981b3f691
Security and correctness:
- GitBranchSync: refuse the push when branch.<name>.merge names a different
  branch. Git does not require an upstream to share its branch name, and the
  open-PR gate is evaluated on the upstream name, so it passed precisely when
  the names differed - publishing local commits onto someone else's PR branch
  or onto the base branch. Refusal routes to the agent fallback. (F6)
- Auto-sync now distinguishes "PR status not loaded yet" from "no pull
  request". PerRepoState.PrData is an option, ReadPrStatus returns an option,
  and a sessionless worktree defers to the next refresh instead of merging
  locally and recording an accepted revision it can never push. An open
  session is unaffected - it resolves PR state itself. (F8)
- GithubPrStatus: a fetched PR counts only when its head repository is one this
  checkout could push to (configured remotes' push-URL owners plus the upstream
  owner). GitHub matches by bare head ref, so an outsider's fork PR could
  otherwise open a same-named local branch's push gate. (F7)
- GitBranchSync: -- now precedes the fetch remote, so a dash-leading remote name
  cannot be parsed as a git option. (F5)

Modelling and duplication:
- PrInfo carries a three-state PrState (Open | Merged | ClosedUnmerged) instead
  of independent IsOpen/IsMerged booleans, so the illegal combinations are
  unrepresentable and each provider maps its single status fact once. (F11)
- Delete AutoSyncLaunchesInFlight: it is nested inside the operation guard and
  could never refuse, so the operation guard is the only guard. (F9)
- WorktreeApi reuses the injected ClearAcceptedRevision instead of duplicating
  AutoSyncStore.clear. (F10)

Specs:
- canvas-pane.md no longer claims server-queue coalescing that SessionBridge
  does not implement and the Decisions section denies. (F1)
- worktree-monitor.md describes the real push behaviour instead of a
  "pushed on the next cycle" guarantee no code path provides. (F2)
- Restore the GitWorktree.fs Key Files responsibilities the branch dropped (F4)
  and list src/Extension/send-queue.mjs (F3).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 80f775f5-e421-45b9-acac-d4f981b3f691
Copilot AI review requested due to automatic review settings July 28, 2026 22:46

Copilot AI 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.

Pull request overview

Adds sessionless mechanical Git synchronization with durable deduplication, safer PR-state handling, and a serialized extension send queue.

Changes:

  • Adds guarded fetch/merge/push synchronization with agent fallback.
  • Persists accepted sync revisions and introduces explicit PR states.
  • Extracts scheduler state and extension queue logic into dedicated modules.
Show a summary per file
File Description
src/Tests/WorktreePathResolutionTests.fs Updates scheduler-state references and API arguments.
src/Tests/UpstreamRemoteTests.fs Imports extracted scheduler state.
src/Tests/Tests.fsproj Registers new sync/store tests.
src/Tests/SessionActivityServiceTests.fs Updates scheduler mailbox types.
src/Tests/ServerLifecycleTests.fs Uses extracted scheduler agent.
src/Tests/SchedulerTests.fs Covers new stores and PR state.
src/Tests/PrFilteringTests.fs Migrates PR assertions to PrState.
src/Tests/OverviewSnapshotCaptureTests.fs Updates scheduler-state references.
src/Tests/OverviewHistoryApiTests.fs Updates API construction.
src/Tests/MergedPrStoreTests.fs Migrates merged-state fixtures.
src/Tests/IdleDetectionTests.fs Imports extracted scheduler state.
src/Tests/GithubFixtureTests.fs Tests PR owners and branch reuse.
src/Tests/fixtures/worktrees.json Updates serialized PR state.
src/Tests/fixtures/github/pr-list-fork.json Adds fork-owner fixture data.
src/Tests/fixtures/github/pr-list-branch-reuse-open.json Adds open reused-branch fixture.
src/Tests/fixtures/github/pr-list-branch-reuse-closed.json Adds closed reused-branch fixtures.
src/Tests/DiffViewerTests.fs Updates scheduler-state references.
src/Tests/DiffEndpointTests.fs Updates scheduler mailbox types.
src/Tests/CodingToolSinceTests.fs Imports extracted scheduler state.
src/Tests/CliTests.fs Migrates CLI fixtures to PrState.
src/Tests/CanvasDocServerTests.fs Updates scheduler-state references.
src/Tests/BranchSyncTests.fs Tests mechanical merge and push behavior.
src/Tests/AzDoFixtureTests.fs Tests parsed Azure PR states.
src/Tests/AutoSyncStoreTests.fs Tests persistence and corruption handling.
src/Shared/Types.fs Introduces three-state PR status.
src/Server/WorktreeApi.fs Wires auto-sync storage and cleanup.
src/Server/SessionActivityService.fs Uses extracted scheduler protocol.
src/Server/SessionActivityRuntime.fs Updates scheduler mailbox types.
src/Server/SessionActivity.fs Updates scheduler documentation reference.
src/Server/Server.fsproj Registers new server modules.
src/Server/SchedulerState.fs Extracts dashboard state and mailbox agent.
src/Server/RefreshScheduler.fs Orchestrates durable mechanical auto-sync.
src/Server/PrStatus.fs Adds PR states and trusted-owner discovery.
src/Server/Program.fs Creates and flushes runtime stores.
src/Server/OverviewSnapshotCapture.fs Uses extracted scheduler state.
src/Server/MergedPrStore.fs Migrates persisted PR reconstruction.
src/Server/GithubPrStatus.fs Parses PR states and filters head owners.
src/Server/GitBranchSync.fs Implements mechanical merge and push.
src/Server/DemoFixture.fs Migrates demo PR fixtures.
src/Server/CanvasDocServer.fs Updates scheduler mailbox types.
src/Server/AutoSyncStore.fs Adds durable accepted-revision storage.
src/Server/AutoSync.fs Adds target selection and mechanical fallback.
src/Extension/session-prompt.test.mjs Tests typed prompt decoding.
src/Extension/session-prompt.mjs Returns prompt kind with text.
src/Extension/send-queue.test.mjs Tests queue serialization and coalescing.
src/Extension/send-queue.mjs Implements serialized send queue.
src/Extension/extension.mjs Integrates the extracted queue.
src/Client/CardViews.fs Renders three-state PR data.
src/Cli/Program.fs Formats merged state from PrState.
docs/spec/worktree-monitor.md Documents mechanical auto-sync architecture.
docs/spec/user-idle-detection.md Updates scheduler module references.
docs/spec/session-status-push.md Documents sessionless sync behavior.
docs/spec/overview-activity-history.md Updates scheduler state references.
docs/spec/future/code-improvements.md Records scheduler-state extraction.
docs/spec/canvas-pane.md Documents queue coalescing.
docs/spec/canvas-interaction-routing.md Reformats queue behavior text.
docs/spec/beads-overview-band.md Updates scheduler module references.

Review details

  • Files reviewed: 58/58 changed files
  • Comments generated: 5
  • Review effort level: Medium

Comment thread src/Server/GitBranchSync.fs Outdated
Comment thread src/Server/GitBranchSync.fs
Comment thread src/Server/AutoSync.fs
Comment thread src/Server/SchedulerState.fs
Comment thread src/Server/AutoSync.fs
0101 and others added 17 commits July 29, 2026 01:16
- Run every mechanical-sync git command with repository hooks disabled.
  pre-merge-commit, post-merge and pre-push are project-controlled scripts, and
  this path runs unattended with no session watching it - verified that an
  unguarded merge does execute a repo's post-merge hook. Pointing core.hooksPath
  at a directory Treemon never creates disables them all; --no-verify would not,
  since it leaves post-merge running.
- Prove the worktree clean twice: before the fetch and again immediately before
  the merge. The fetch reaches the network and may run for up to 120s, so the
  tree proven clean before it is not evidence about the tree being merged into.
- Treat an accepted record stamped in the future as invalid. now - AcceptedAt is
  negative after a clock rollback or a hand-edited runtime file, which is
  trivially under the retry age, so the revision stayed suppressed until that
  future time instead of for one hour.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 80f775f5-e421-45b9-acac-d4f981b3f691
An open terminal was treated as an owner: any session seen inside the openness
window kept the sync on the agent path, so a worktree parked overnight with a
CLI still running never got synced. Waiting for the terminal to close is a
condition no user would guess.

Openness and activity are now separate questions. SyncTarget gains a case -
WorkingSession | IdleSession | NoOpenSession - and only a session mid-turn keeps
the agent path; idle and waiting-for-user are synced mechanically like a worktree
with no session at all. Because it is a DU, the compiler flagged every routing
decision the split had to revisit.

- Background agents count as work: the effective status already reports Working
  while one runs, so a session that spawned one is protected between its turns.
- A failed mechanical sync prompts the idle session itself rather than launching
  a second terminal.
- Accepted risk: idle is a turn status, not a machine status, so a clean tree with
  a build running outside a background agent can still be merged under. The
  clean-tree refusal covers everything with work in progress.

The auto-sync toggle now reports progress instead of only going dead while its
request is in flight: the two-arrow glyph spins, the tooltip reads "Syncing
with {base}...", and it stays undimmed so working is distinct from disabled.
prefers-reduced-motion drops the rotation and keeps the tooltip. This matters
more now that idle sessions sync mechanically - the awaited toggle reaches real
Git work in the common case rather than the rare one.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 80f775f5-e421-45b9-acac-d4f981b3f691
selectTargetFromSessions had reimplemented pickActive's filter-sort-take-head
shape with its own status predicate, which is the same selection rule written
twice. Both meanings now come from one private core in SessionActivity, the
module that owns session-status collapse:

- pickActive  - drop Idle, so a session waiting on its user still lights the dot.
- pickWorking - only a session mid-turn, which is what owns a worktree for sync.

The two rules differ by exactly one predicate and now say so, instead of an
ad-hoc isWorking helper in AutoSync restating the pipeline.

No behaviour change.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 80f775f5-e421-45b9-acac-d4f981b3f691
Six call sites passed ten positional arguments, and the optional stores read as
a run of bare Nones - None None None roots None "1.0" None in the test
helpers. Two of the ten are string option (TestFixtures and DeployBranch), so a
transposition would have compiled and silently swapped them.

WorktreeApiDependencies names each one. The body destructures the record in a
single bind, so the change is the signature and the call sites rather than 800
lines of member code, and fixture-mode wiring now reads as what it configures.

No behaviour change.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 80f775f5-e421-45b9-acac-d4f981b3f691
Reported from a real deploy: clicking auto-sync gave no feedback even though the
request took 4 seconds and the sync happened.

The cause was the spinner I added. It fed the whole in-flight state through an
animation, and then disabled that animation under prefers-reduced-motion while
also setting opacity back to 1 - which cancelled the dimming the disabled button
used to provide. On a machine with Windows animations turned off the browser
reports reduced motion, so the in-flight toggle rendered identically to the
resting one: strictly less feedback than before the spinner existed.

The state now reads statically - the palette's yellow in-progress tint on the
glyph and border - and keeps the rotation where motion is allowed. That also
separates "working" from the green "enabled" state instead of relying on motion
alone to carry it.

The regression test compares computed paint between the resting and syncing
toggle, so an animation-only cue fails it. It samples after the button's 0.15s
colour transition, since an immediate read returns the pre-transition value.
Two existing assertions on the in-flight class string now expect the syncing
marker.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 80f775f5-e421-45b9-acac-d4f981b3f691
Treemon's mechanical sync conflicted and aborted; resolved by hand.

main added repository-declared diff categories (#155) and consolidated the
dashboard font sizes (#156) while this branch moved the scheduler state slice
into SchedulerState and reshaped worktreeApi's dependencies, so the two collided
on five files:

- WorktreeApi: kept both sides - main's diffCategoryReport helper and this
  branch's WorktreeApiDependencies record. The record already carries
  AutoSyncStore, which main's positional signature did not have.
- CanvasDocServer: took main's resolveDiffTarget (it replaces the older
  getDiffComparisonContext) and retyped it to SchedulerState.StateMsg, dropping
  the stale duplicate resolver body this branch still had.
- DiffEndpointTests: took main's deletion - those helpers now live in the new
  DiffEndpointTestHelpers.fs. This branch's only edits there were the module
  rename, which is reapplied below.
- CanvasDocServerTests: kept main's new repoId binding with this branch's
  SchedulerState.createAgent.
- worktree-monitor.md: kept this branch's GitWorktree/GitBranchSync rows and
  took main's TreemonConfig row, which now names the diffCategories read.

main's new files still referenced RefreshScheduler for the state members this
branch moved, so DiffEndpointTestHelpers.fs, DiffCategoryEndToEndTests.fs and
one WorktreeApi call site were repointed at SchedulerState.

Server, client and tests build clean; fast suite 1878 passed.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 80f775f5-e421-45b9-acac-d4f981b3f691
Durable state is split between SQLite (SessionActivityStore) and per-port JSON
files behind PersistentStore + JsonStore (MergedPrStore, AutoSyncStore,
CanvasDocOwnership). Each store's choice fits its shape, but two mechanisms is a
standing cost, so record it as a candidate rather than leaving it as folklore.

Notes the two things that would otherwise be re-litigated: durability is not the
gap (JsonStore already writes temp-then-move), and all three stores have to move
together or the converted one becomes the odd store out.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 80f775f5-e421-45b9-acac-d4f981b3f691
A session mid-turn was asked to sync itself, which put the prompt into the
CLI's own queue -- a queue Treemon cannot observe. When the agent stayed busy
past the one-hour acceptance window, the same base revision was observed again
and prompted a second time, leaving two identical messages queued and repeating
every hour.

A busy worktree is now waited for: nothing is delivered, nothing is mutated and
nothing is recorded, so every sync runs through the single mechanical path and
spends an agent only on what Git could not finish. That removes the failure
mode rather than detecting it, and the sync lands sooner without an agent turn.

"Idle" must be settled rather than sampled: status dips to idle for
milliseconds between back-to-back turns, so an instantaneous reading would let
a fetch and merge start under an agent about to resume. Over 3,267 real
inter-turn gaps, 87% are under half a second and only 0.7% fall between two
seconds and 30s, so settleWindow sits in an empty valley.

With the working-session branch gone, SyncPlan has one case left and is
replaced by the PR status it carried.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 3e93e674-4f32-40a0-b019-1b439cf8a39a
- AutoSync.fs:202 - the settle check read UpdatedAt raw, but it is a
  last-write-wins ordering clock: the MinValue sentinel of a session whose
  only report is a title/intent hydration cleared a 30s window by two
  thousand years, and a stamp ahead of server time (reports are clamped
  only 5 min ahead) read as negative idleness and pinned the worktree busy.
  Both now go through hasSettled.
- AutoSync.fs:343 - the fallback prompt reused the target selected before
  the fetch and merge, so a session that resumed meanwhile was still
  prompted - and a resuming agent is the likeliest cause of the dirty
  worktree that brings the sync there. The target is re-selected beside the
  existing eligibility re-read and the prompt is dropped when it is busy.
- AutoSync.fs:378 - the eligibility gate (a config read under the
  process-wide lock plus a scheduler mailbox round-trip) was computed and
  discarded on every deferred observation. The target is read first.
- Two spec surfaces still described the deleted working-session delivery
  path, and the unloaded-PR deferral is no longer sessionless-only.

Left for a decision, per the review: SessionBusy living in the delivery
address DU, an unbounded deferral when a session is stuck Working, the
settle window applying to WaitingForUser, and the toggle returning Ok on a
deferred worktree.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 3e93e674-4f32-40a0-b019-1b439cf8a39a
SessionBusy was a case of SyncTarget, the same DU carried by
DeliveryRequest.Target and read by deliver -- so "deliver into a worktree
somebody is working in" was representable, and kept out only by an
exhaustive match rather than by the type. SyncTarget.sessionId also had to
answer None for two opposite meanings: "never deliver here" and "no
retained identity, launch a new CLI".

WorktreeOwnership = Busy | Free of SyncTarget now carries the verdict, and
SyncTarget holds addresses only. Delivery can be reached solely by
unwrapping Free, so the invariant is in the type instead of in a comment,
and SyncTarget.sessionId is total and unambiguous.

SelectTarget/selectTarget are renamed to ReadOwnership/readOwnership, since
they answer who owns the worktree before they answer where to deliver.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 3e93e674-4f32-40a0-b019-1b439cf8a39a
The in-flight spin was suppressed under prefers-reduced-motion, and Windows
"animation effects: off" makes browsers report exactly that - so on such a
machine a multi-second fetch and merge showed only a static yellow border,
which reads as a disabled button rather than as work in progress.

The suppression is dropped by decision: a 13px glyph turning in place is
not the vestibular motion the media query exists to protect against, and it
is the only cue that conveys "still running" rather than "stuck". The
static yellow tint stays as the second, screenshot-readable half of the
state.

The regression test now pins both halves - paint must differ from the
resting toggle AND the glyph must report a running sync-spin animation - so
neither cue can be dropped silently again. It also drops a dead shadowed
binding left in that test.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 3e93e674-4f32-40a0-b019-1b439cf8a39a
A completed mechanical sync left the card reporting "1 behind main" until
the next scheduled RefreshGit came round, even though the sync had just
cleared it. The existing ExpediteRefresh does not cover this: it only drops
the RefreshWorktreeList stamp, which refreshes list membership rather than
git data, and it still waits for the scheduler loop to come back around.

RefreshGit's body becomes reloadGitData, shared with a new ReloadGitData
dependency that AutoSync calls after - and only after - MechanicalSync
returns Ok. The observation that was invalidated is refreshed by the code
that invalidated it, with no scheduler timing in the path. A sync that
stopped changes nothing, so it does not reload.

tryFindOwningRepo moves above the dependency wiring that now resolves the
repository for a worktree path; it is unchanged.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 3e93e674-4f32-40a0-b019-1b439cf8a39a
The toggle's glyph was two straight arrows - a swap symbol. Rotating it
while a sync runs read as a symbol falling over rather than as work in
progress, which is what made the newly-visible animation look wrong.

The same two arrows, curved into a circle, keep the two-way sync meaning
and make rotation the motion the shape already implies. Chosen from a live
canvas mock of six candidates (single arrow, two arrows, arc spinner,
stretching ring, dancing dots, breathing) compared at real 13px on a card
row.

It replaces the resting glyph too, so the button keeps one shape and simply
starts turning when a sync begins, rather than swapping icons mid-state.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 3e93e674-4f32-40a0-b019-1b439cf8a39a
The spec says deleting a worktree clears its accepted-revision record, but
only the API deletion path did. A worktree removed any other way - git
worktree remove, a manual delete - never gets another observation, so
nothing cleared its record and a worktree later recreated at that path
could inherit the suppression and lose its first sync for up to an hour.
The records also accumulated forever in the runtime file.

Discovery already computes the removed set for its own pruning, so the
clearing hangs off the same signal. It is guarded on the discovery having
succeeded: listWorktrees answers None on a Git failure, which must never
read as every worktree vanishing and wipe live records. removedWorktreePaths
is a pure function so that guard is directly testable.

Raised by the Copilot PR reviewer on #154 and deferred there as an owner
decision; resolved in favour of the code matching the documented lifecycle
rather than weakening the spec.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 3e93e674-4f32-40a0-b019-1b439cf8a39a
@0101
0101 merged commit 45e49c4 into main Jul 31, 2026
1 check 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.

2 participants