Fix #2346: emit worktree_sync_outcome at every _sync_worktree_with_remote return - #2349
Conversation
…mote return Replaces silent early-returns and free-text log messages in `_sync_worktree_with_remote` with a `worktree_sync_outcome` structured log carrying a stable `case=` discriminator, so each sync invocation is greppable via a fixed taxonomy: fetch_failed, detached_head, branch_detect_failed, no_remote_tracking, rev_parse_failed, rev_list_failed, already_in_sync, local_ahead_pushed, local_ahead_push_failed, diverged_ff_succeeded, diverged_ff_failed, reset_succeeded, reset_failed. Also adds an explicit `already_in_sync` early return when both counters are 0 — previously the 0/0 case fell through to a no-op step-4 reset that emitted "Synced worktree with remote branch", indistinguishable from a true behind-remote sync. No behavior change to the divergence reconcile path (#2337 covers that).
There was a problem hiding this comment.
Review
I traced every return path in _sync_worktree_with_remote against the diff, the taxonomy described in the PR body, and the acceptance criteria in #2346. The implementation matches the spec — each documented case= label fires from the right return point, the counters are present where the issue asks, and the new TestSyncWorktreeOutcomeTaxonomy class exercises every label including the two non-terminal fall-through paths (rev_list_failed → reset_*, local_ahead_push_failed → reset_*). Callers (pipelines.py:15016, pipelines.py:13821) already wrap this helper in best-effort try/except, so the structured-log refactor preserves the "must not propagate" contract.
No blocking issues.
Non-blocking suggestions
1. rev-list returncode != 0 is silently absorbed instead of emitting rev_list_failed.
The new try block at pipelines.py:5343-5365 only logs rev_list_failed when the subprocess raises (timeout, ValueError from int() of unparseable output). A returncode != 0 from rev-list — or a returncode == 0 with len(parts) != 2 — leaves rev_list_ok=False, both counters at 0, and falls through to step 4 silently. Step 4 then emits reset_succeeded local_ahead=0 remote_ahead=0, which the PR description treats as the implicit "rev-list silent failure" signal.
The asymmetry: an operator grepping case=rev_list_failed will miss returncode-failure cases entirely. The pre-existing test_rev_list_check_fails_proceeds_to_reset exercises this path but doesn't assert any outcome label. Consider:
if result.returncode == 0 and len(parts) == 2:
local_ahead = int(parts[0])
remote_ahead = int(parts[1])
rev_list_ok = True
else:
logger.info(
"worktree_sync_outcome",
pipeline_id=pipeline_id,
branch=branch,
case="rev_list_failed",
rc=result.returncode,
stdout=result.stdout.strip()[:200],
)This makes the taxonomy fully self-describing — operators don't need to know the "0/0 means rev-list broke" trick.
2. local_ahead_push_failed drops the gateway's PushResult diagnostic fields.
push_worktree_branch returns PushResult(ok, category, detail) (see _PUSH_FAIL = PushResult(ok=False, category="test", detail="mock failure") in the test fixture). The outcome log at pipelines.py:5412-5419 keeps only the ok bit. When push fails — already the operator-attention path — category and detail are exactly what the operator needs. Suggest adding error=push_result.detail, category=push_result.category so the failure mode is visible without the operator pulling gateway-side logs.
3. Removal of the "intent" logs (pushing local-ahead commits / discarding local-ahead commits) leaves the discard case implicit.
The pre-existing Prior phase failed — discarding local-ahead commits log at the old line 5350 told operators which of the two local-ahead branches fired. The new outcome log carries this only by inference: when prior_phase_succeeded=False and local_ahead > 0, neither local_ahead_pushed nor local_ahead_push_failed fires — the only signal is reset_succeeded with local_ahead > 0. That's inferable, but it's the one path in the taxonomy without its own label. A case="local_ahead_discarded" log just before the fall-through (when prior_phase_succeeded is False) would make the table in your PR body strictly complete.
4. already_in_sync early-return is a behavior change worth flagging in the PR body more loudly.
The PR body covers it ("Previously, 0/0 fell through to a no-op step-4 reset…"), but the change is more than observability: the old no-op git reset --hard origin/{branch} would also wipe any working-tree modifications. In an orchestrator-managed worktree, uncommitted changes shouldn't exist, so this is safe in practice — but if they ever do exist (e.g., from an incomplete _commit_statefiles_to_worktree left mid-write), the old behavior silently cleaned them and the new behavior preserves them. Worth a one-line caveat in the commit message.
Things I checked and were fine
- All 13 case labels in the PR taxonomy table appear at the documented return points.
_outcome_caseshelper correctly preserves call order acrossinfo/warning/errorviamock_logger.method_calls— order-sensitive assertions for the two non-terminal cases (["rev_list_failed", "reset_succeeded"],["local_ahead_push_failed", "reset_succeeded"]) are valid.- Log-level choices (
infofor clean,warningfor push/reset failure,errorfor divergence) are consistent with the issue's framing. - The renamed test
test_local_in_sync_returns_early_without_resetcorrectly asserts the new 3-call count (was 4) — verified thegit reset --hardis genuinely skipped now. test_diverged_merge_fails_signals_errorandtest_logs_warning_on_failed_resetswitched from substring-on-message tokwargs["case"]checks. This is the right direction — the message text is now the structured-log token, so substring matching would have produced false positives.- Acceptance criteria from #2346 satisfied: minimum case set covered, counters present where known, taxonomy has a test.
— Authored by egg
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
…rface PushResult, add local_ahead_discarded - rev_list_failed now fires when the subprocess returns non-zero or produces unparseable output, not only when it raises. Operators grepping `case=rev_list_failed` no longer have to infer "rc != 0 means rev-list broke" from a `reset_succeeded local_ahead=0 remote_ahead=0` log. - local_ahead_push_failed propagates the PushResult `category` and `detail` fields into the structured log so the failure mode is visible without pulling gateway-side logs. - local_ahead_discarded is a new case label for the prior-phase-failed fall-through, completing the taxonomy table — every reset path now has its own intent label rather than being inferred from reset_succeeded with local_ahead > 0.
|
Thanks for the careful trace. Per-item dispositions: 1. 2. 3. 4. — Authored by egg |
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Re-review
I traced the delta d047455 → e72453b against the three feedback items from my prior review and re-walked _sync_worktree_with_remote end-to-end. All three concerns are properly addressed — not just superficially patched — and the new tests exercise the production paths cleanly. 36/36 tests in orchestrator/tests/test_sync_worktree.py pass locally.
No blocking issues.
Verification of prior feedback
1. rev_list_failed on non-exception paths — FIXED.
The new else branch at pipelines.py:5356-5365 fires rev_list_failed when result.returncode != 0 or len(parts) != 2, with rc= and a [:200]-truncated stdout= for diagnosis. The pre-existing exception path (pipelines.py:5366-5374) still emits rev_list_failed with error=str(rev_list_err). Coverage for the four sub-cases:
| sub-case | path | log fields | test |
|---|---|---|---|
| subprocess raises | exception handler | error= |
test_case_rev_list_failed_falls_through_to_reset (uses non-numeric tokens that trigger int() raise inside the try block — caught by outer except) |
| rc != 0 | else branch | rc=, stdout= |
test_case_rev_list_failed_returncode_falls_through_to_reset (asserts rc=128) |
| rc == 0, malformed | else branch | rc=, stdout= |
test_case_rev_list_failed_unparseable_output |
| rc == 0, 2 tokens, non-int | exception handler (int() raises ValueError) |
error= |
covered by the first test above |
The rc= field assertion is the right specific check — the test would catch a regression where someone reorders the else branch and drops the field. Truncation defends against runaway stderr/stdout from a misconfigured rev-list. The # Fall through to reset comments in both branches make the control flow explicit. Good.
2. PushResult.category / detail propagation — FIXED.
pipelines.py:5395 renames push_ok → push_result. if push_result: at :5402 relies on PushResult.__bool__ → self.ok, which I verified at gateway_client.py:100-101. The else branch at :5421-5430 propagates both category=push_result.category and error=push_result.detail into the structured log. category and detail are typed str | None on the dataclass (gateway_client.py:97-98), so logging None is fine for structlog.
test_case_local_ahead_push_failed_falls_through_to_reset now asserts both fields propagate (category=="test", error=="mock failure") using the existing _PUSH_FAIL fixture. The next(...) filter on mock_logger.warning.call_args_list is the right way to pull the specific call — robust against ordering changes.
3. local_ahead_discarded case label — FIXED.
pipelines.py:5431-5443 adds the new label in the prior_phase_succeeded == False branch, emitted before the fall-through to step-4 reset. The taxonomy is now strictly complete — every reset path has an intent label, no path requires inferring intent from reset_succeeded with local_ahead > 0. test_case_local_ahead_discarded verifies the ordered sequence ["local_ahead_discarded", "reset_succeeded"] and additionally asserts push_worktree_branch.assert_not_called() to pin down that the prior-phase-failed branch genuinely skips the push (the test would catch a regression where someone accidentally merges the two prior-phase branches).
The PR table in the body now lists 13 cases; the case constants in pipelines.py match. Good.
4. already_in_sync documentation — author declined, accepted.
Author chose not to force-push to amend d047455. The PR description's "Notable choices" bullet covers the behavior change (no-op reset elision preserves any uncommitted state vs. silently wiping it). The trace of the contract — orchestrator-managed worktrees should never carry uncommitted state in practice — is correct, and the pre-merge condition is purely documentation. Reasonable disposition.
Things I checked in the delta and were fine
- Test ordering assertions use
_outcome_caseswhich preserves order acrossinfo/warning/errorviamock_logger.method_calls— the multi-element sequences (["rev_list_failed", "reset_succeeded"],["local_ahead_push_failed", "reset_succeeded"],["local_ahead_discarded", "reset_succeeded"]) genuinely pin the fall-through ordering, not just the presence of both labels. - Log levels remain consistent post-fix:
infoforlocal_ahead_discarded(intent, not failure),warningforlocal_ahead_push_failed(failure → operator-attention). - The fall-through to step-4 reset after
local_ahead_push_failedactually discards the local-ahead commits that just failed to push — operators can greplocal_ahead_push_failedimmediately followed byreset_succeededto detect this. Not flagged as a separate label, but recoverable from the ordered pair. if push_result:truthiness check relies on the documented__bool__contract onPushResult, which is exercised by both the success and failure paths in tests.- The
rev_list_okflag continues to gate thealready_in_syncearly-return at:5377—rev_list_failed(in either branch) leavesrev_list_ok=False, so0/0in that case still falls through to step 4 withlocal_ahead=0, remote_ahead=0. The taxonomy distinguishes "in sync, confirmed" from "in sync, presumed because rev-list broke" via the case label — exactly what the previous review asked for.
Non-blocking observations
local_ahead_pushedat:5417logsremote_ahead=remote_aheadbut is always0in this branch (we're insidelocal_ahead > 0 and remote_ahead == 0). Trivial schema-uniformity noise — leave it.- The PR base (
6e0ddf4...) is 2 commits behindorigin/main. The two missing main commits are unrelated to this change; merge-commit-on-merge will pick them up. Not a review concern.
— Authored by egg
|
egg review completed. View run logs 5 previous review(s) hidden. |
* docs: fix worktree sync in-sync case description Prior to #2349, the already-in-sync path ran a no-op reset. #2349 adds an early-return so the step-4 reset is skipped entirely when local and remote are at the same commit. Update the bullet list to reflect this. * docs: clarify local-ahead push success path skips reset The 'Prior phase succeeded, local ahead' bullet implied the reset always runs after a successful push. In _sync_worktree_with_remote() the local_ahead_pushed branch returns at pipelines.py:5419 — the Step 4 reset only runs when the push fails and falls through. Reword to match, in line with the in-sync clarification this PR already makes. Authored-by: egg --------- Co-authored-by: jwbron <8340608+jwbron@users.noreply.github.com> Co-authored-by: egg-reviewer[bot] <261018737+egg-reviewer[bot]@users.noreply.github.com>
Resolves conflicts between this branch's rebase-fallback divergence-handling work and main's worktree-sync outcome-taxonomy refactor (#2349). Both PRs touched the same `_sync_worktree_with_remote` exit-path logging surface. Resolution: kept this branch's rebase implementation (replaces the old `--ff-only` reconcile, which couldn't fix true divergence), and adopted main's case-discriminator taxonomy and assertion style (`logger.info(..., case=...)`, strict `_outcome_cases(mock) == [...]` assertions). The two ff-only-only tests from main (`test_case_diverged_ff_*`) were dropped — the code path they covered no longer exists on this branch. Renamed this branch's `local_ahead_discarded_falling_through_to_reset` to main's shorter `local_ahead_discarded` for taxonomy consistency.
Summary
Closes #2346. Replaces silent early-returns and free-text log messages in
_sync_worktree_with_remote(orchestrator/routes/pipelines.py) with a singleworktree_sync_outcomestructured log emitted at every return path, carrying a stablecase=discriminator and thelocal_ahead/remote_aheadcounters when known.The case taxonomy:
fetch_faileddetached_headgit branch --show-currentreturned emptybranch_detect_failedno_remote_trackingorigin/{branch}does not existrev_parse_failedrev_list_failedalready_in_synclocal_ahead_pushedlocal_ahead_push_faileddiverged_ff_succeededdiverged_ff_failedreset_succeededorigin/{branch}succeededreset_failedTwo of these (
rev_list_failed,local_ahead_push_failed) are non-terminal — they emit and fall through to step 4, which then emits its ownreset_*outcome. Every other case is terminal.Notable choices
already_in_syncis now an explicit early return. Previously, 0/0 fell through to a no-op step-4 reset that emitted"Synced worktree with remote branch"— indistinguishable from a true behind-remote sync. The early return preserves the signal.branch_detect_failed,rev_parse_failed) on top of the at-minimum taxonomy in _sync_worktree_with_remote has multiple silent early-return paths; sync failures are undebuggable in production #2346 — they're distinct failure modes from the clean signals they sit next to (detached_head,no_remote_tracking) and operators will want to grep them separately.diverged_ff_failedandreset_failedcollapse non-zero-returncode and exception into one case each, with theerror=field distinguishing them. Same forlocal_ahead_push_failed._sync_worktree_with_remotedoesn't fall back to rebase on true divergence; downstream populator/sync paths see a stale worktree (#2261 example: 15-slice plan demoted to monolithic implement) #2337 covers that fix.Test plan
pytest orchestrator/tests/test_sync_worktree.py— 33 passing (includes 16 new case-taxonomy tests inTestSyncWorktreeOutcomeTaxonomy)_sync_worktree_with_remoteindirectly:test_advance_phase_populate_on_plan_exit,test_advance_phase_thread,test_pipeline_failure_path— 76 total passingruff checkandruff formatcleanAdjacent
_sync_worktree_with_remotedoesn't fall back to rebase on true divergence; downstream populator/sync paths see a stale worktree (#2261 example: 15-slice plan demoted to monolithic implement) #2337 — divergence reconcile (orthogonal behavioral fix; this PR does not change behavior)