---
phase: 35-psyche-sync-cross-machine-context-backup-via-private-gh-repo
plan: 02
subsystem: common/sync
tags: [sync, git, backoff, gh-cli, soft-fail, phase-35]
requires:
  - "owlery::{SyncSettings, SyncState, read_sync_settings, write_sync_settings} (Plan 35-01)"
  - "git::{run_git_checked, GitError} (Phase 24)"
  - "time::now_iso_utc (Phase 24.1)"
provides:
  - "sync::pull_branch(branch, worktree) -> Result<(), GitError>"
  - "sync::push_branch(branch, worktree) -> Result<(), GitError>"
  - "sync::sync_after_commit(branch, worktree)"
  - "sync::maybe_add_origin(worktree)"
  - "sync::is_backoff_active(&SyncSettings, now_iso) -> bool"
  - "sync::next_delay(consecutive_failures) -> Duration"
  - "sync::abort_stale_rebase(worktree) -> bool"
  - "sync::classify_and_record_outcome<T>(&Result<T, GitError>, branch) [pub(crate)]"
  - "sync::gh_present() -> bool"
  - "sync::SYNC_TIMEOUT (pub(crate) const = 30s)"
affects:
  - "Wave 3+ plans (hook_prompt dispatcher, post-commit hook, doctor surface, session-start gate, accept_flow) — all consume this facade"
tech-stack:
  added: []
  patterns:
    - "by-reference Result inspection to dodge non-Clone GitError (B1 Resolution C)"
    - "two-step fetch+rebase (NOT pull --rebase) for missing-upstream first sync (Pitfall 4)"
    - "pre-check rebase-merge/rebase-apply before rebase --abort (Pitfall 2)"
key-files:
  created:
    - src/common/sync.rs
    - tests/source_order_sync.rs
  modified:
    - src/common/mod.rs
decisions:
  - "Classifier takes &Result<T, GitError> by reference — GitError is not Clone (std::io::Error E0204); no Clone derive added to git.rs"
  - "gh_present uses raw std::process::Command (gh is not git) with a 500ms fixed wait + try_wait; no run_git_checked plumbing"
  - "iso_plus_duration uses chrono parse_from_rfc3339, falls back to now_iso verbatim on parse failure (gate fires immediately next attempt)"
metrics:
  duration: ~4min
  completed: 2026-05-26
  tasks: 2
  files: 3
---

# Phase 35 Plan 02: Sync subprocess facade Summary

Created `src/common/sync.rs` — the Phase 35 subprocess facade composing Phase 24 `git::run_git_checked` + Plan 35-01 `SyncSettings` + a new gh-CLI probe into 9 pub helpers with one shared soft-fail / 30s-budget / rebase-recovery / backoff-classifier surface so all Wave-3+ consumers share identical failure semantics.

## What Was Built

Nine helpers in `src/common/sync.rs` (registered via `pub mod sync;` in `src/common/mod.rs`):

| Helper | Signature | Role |
|--------|-----------|------|
| `is_backoff_active` | `(&SyncSettings, &str) -> bool` | pure ISO-8601 lexicographic gate predicate; no subprocess |
| `next_delay` | `(u32) -> Duration` | D-18 schedule 60/300/900/3600/21600/86400s, capped 24h |
| `abort_stale_rebase` | `(&Path) -> bool` | Pattern 3 pre-check + linked-worktree gitdir resolve before `rebase --abort` |
| `gh_present` | `() -> bool` | raw `Command` probe of `gh --version`, no panic on missing gh |
| `classify_and_record_outcome<T>` | `(&Result<T, GitError>, &str)` [pub(crate)] | by-reference outcome classifier → persists SyncSettings transition |
| `pull_branch` | `(&str, &Path) -> Result<(), GitError>` | abort → stash → fetch origin → rebase -X theirs (Pitfall 4) |
| `push_branch` | `(&str, &Path) -> Result<(), GitError>` | `push origin {branch}` within 30s budget |
| `sync_after_commit` | `(&str, &Path)` | gated pull-then-push (D-04), soft-fail (D-14) |
| `maybe_add_origin` | `(&Path)` | Enabled-gated idempotent `remote add origin` |

Plus `pub(crate) const SYNC_TIMEOUT: Duration = Duration::from_secs(30)` (D-07).

### By-reference classifier (B1 Resolution C)

`classify_and_record_outcome<T>(result: &Result<T, GitError>, _branch: &str)` inspects the Result via pattern-match WITHOUT taking ownership. `GitError` is **NOT** Clone — its `Io` variant holds `std::io::Error`, which has no Clone impl (E0204). The caller (`pull_branch`/`push_branch`) keeps ownership and converts via `r.map(|_| ())`. **`src/common/git.rs` was NOT modified — `git diff src/common/git.rs` shows zero lines touched, no `Clone` derive added.**

### iso_plus_duration rationale

Private helper parses `now_iso` via `chrono::DateTime::parse_from_rfc3339`, adds the backoff `Duration`, reformats to the `now_iso_utc` byte shape. On parse failure it falls back to `now_iso` verbatim — graceful degrade: the backoff gate then fires immediately on the next attempt, but the failure was still recorded. No new crate dep (chrono already present).

### Pitfall 4 mitigation (grep-gate)

`pull_branch` uses a TWO-STEP `git fetch origin {branch}` + `git rebase -X theirs origin/{branch}`, never `git pull --rebase` (which fails when no upstream tracking exists on first sync). Verified by `tests/source_order_sync.rs::pull_branch_uses_fetch_not_pull_rebase`, which strips `//` comments then asserts:
- `"pull", "--rebase"` count == 0
- `"fetch", "origin"` count >= 1

## Tasks Completed

| Task | Name | Commit | Files |
|------|------|--------|-------|
| 1 | Module skeleton + pure predicates (is_backoff_active, next_delay, abort_stale_rebase, gh_present) | b1464fb | src/common/sync.rs, src/common/mod.rs |
| 2 | classify_and_record_outcome (by-ref) + pull/push/sync_after_commit/maybe_add_origin + pin tests | c9f0ca0 | src/common/sync.rs, tests/source_order_sync.rs |

## Tests

- **15 lib unit tests** (`cargo test --lib common::sync::tests -- --test-threads=1`): all pass.
  - is_backoff_active (3 boundary cases incl. now==gate), next_delay schedule (incl. cap), abort_stale_rebase (present/absent), gh_present (bool/no-panic).
  - classifier: Ok-reset, 404→Failing (3 phrase variants), transient nonzero bump+schedule, Timeout transient path.
  - sync_after_commit short-circuits when not Enabled and during backoff (sentinel-file no-op proof).
  - maybe_add_origin: gated no-op when not Enabled; real-git-repo remote add + idempotency (skips gracefully if git not on PATH).
- **5 source-order pin tests** (`cargo test --test source_order_sync`): all pass.
  - pull_branch order (abort < fetch < rebase, -X theirs present), Pitfall 4 grep-gate, sync_after_commit pull-before-push, by-reference signature pin, GitError-non-Clone pin.
- `cargo build --release` clean.

## Deviations from Plan

None functionally. Two minor adjustments worth noting:

1. **gh_present uses `process::hide_window`** — added `crate::common::process::hide_window(&mut cmd)` to the `gh --version` probe to match the crate-wide no-console-flash convention (Windows). Not a deviation from intent; the plan's sketch omitted it. (Rule 2 — consistency/correctness on Windows.)
2. **`run_git_checked` second parameter is `stdin_bytes: Option<&[u8]>`, not `cwd`.** The plan's interfaces block labeled it `cwd: Option<&Path>`, but the actual Phase 24 signature uses `-C <worktree>` in the args for cwd and `Option<&[u8]>` for stdin. The plan's *action* steps already passed `None` for that slot and used `-C` in args, so the implementation followed the action steps correctly. No code change needed; flagging the interface-doc mismatch only.

## Threat Surface

No new trust boundaries beyond the plan's `<threat_model>`. All git argv routes through `run_git_checked` (`Command::args`, no shell interpolation); branch values originate from `agent_branch()`/`project_branch()` (Phase 24 `validate_id_chars`). No new crate deps. The `gh_present` probe runs `gh --version` only (no untrusted args). Nothing to add.

## Known Stubs

None. The module is intentionally consumed by no production code yet — Wave 3+ plans (hook_prompt dispatcher, post-commit hook, doctor, session-start, accept_flow) wire the call sites. This is by design per the plan objective, not a stub.

## Self-Check: PASSED

- FOUND: src/common/sync.rs
- FOUND: tests/source_order_sync.rs
- FOUND: src/common/mod.rs (pub mod sync registered)
- FOUND commit: b1464fb (Task 1)
- FOUND commit: c9f0ca0 (Task 2)
- GitError integrity: `git diff src/common/git.rs` == empty (no Clone derive added)
