Files
kandev/docs/specs/workspaces/system-design/worktree-base-refresh.md
T
2026-09-23 17:59:26 +01:00

433 lines
22 KiB
Markdown

---
status: current
system: workspaces
requirements:
- REQ-WORKSPACES-WORKTREE-BASE-REFRESH-001
---
# Worktree Base Refresh System Design
## Purpose and boundaries
The workspace system owns the repository checkout and the worktree base ref.
This design keeps host worktrees local-first while remote materialization stays
strict.
The task system owns session launch state and error presentation. The
integration system owns provider credentials. Executors own their Git transport
and remote workspace preparation.
This design does not refresh a valid worktree that Kandev reuses. It does not
change Git commands that an agent runs after launch.
## Requirement mapping
| Acceptance criterion | Design section |
| --- | --- |
| `AC-WORKSPACES-WORKTREE-BASE-REFRESH-001.1` | [Refresh policy](#refresh-policy) |
| `AC-WORKSPACES-WORKTREE-BASE-REFRESH-001.2` | [Refresh policy](#refresh-policy) |
| `AC-WORKSPACES-WORKTREE-BASE-REFRESH-001.3` | [Local fallback](#local-fallback) |
| `AC-WORKSPACES-WORKTREE-BASE-REFRESH-001.4` | [Base-ref selection](#base-ref-selection) |
| `AC-WORKSPACES-WORKTREE-BASE-REFRESH-001.5` | [Base-ref selection](#base-ref-selection) |
| `AC-WORKSPACES-WORKTREE-BASE-REFRESH-001.6` | [Required materialization](#required-materialization) |
| `AC-WORKSPACES-WORKTREE-BASE-REFRESH-001.7` | [Multi-repository launch](#multi-repository-launch) |
| `AC-WORKSPACES-WORKTREE-BASE-REFRESH-001.8` | [Required materialization](#required-materialization) |
| `AC-WORKSPACES-WORKTREE-BASE-REFRESH-001.9` | [Empty remote](#empty-remote) |
| `AC-WORKSPACES-WORKTREE-BASE-REFRESH-001.10` | [Failure and recovery](#failure-and-recovery) |
| `AC-WORKSPACES-WORKTREE-BASE-REFRESH-001.11` | [Pull-request base reconciliation](#pull-request-base-reconciliation) |
| `AC-WORKSPACES-WORKTREE-BASE-REFRESH-001.12` | [Pull-request base reconciliation](#pull-request-base-reconciliation) |
| `AC-WORKSPACES-WORKTREE-BASE-REFRESH-001.13` | [Missing-base fallback](#missing-base-fallback) |
| `AC-WORKSPACES-WORKTREE-BASE-REFRESH-001.14` | [Safe refresh diagnostics](#safe-refresh-diagnostics) |
## Components and responsibilities
- `internal/orchestrator/executor` resolves repository paths, refresh routes,
credentials, and executor capabilities. It also resolves live GitHub pull-
request bases through an injected provider seam.
- `internal/worktree.Manager` verifies local refs, attempts host refresh, and
selects the worktree base. Pull-request base refresh remains strict except
for the verified missing-remote-ref fallback.
- `internal/repoclone.Cloner` materializes provider-managed repositories when
no user-owned local checkout supplies the branch.
- `internal/github.Service` reconciles changed pull-request bases through
narrow injected interfaces. Provider lookup and task-repository propagation
are best-effort.
- `internal/agent/runtime/lifecycle` stops launch only when no usable base
remains.
- The task projection shows bounded warnings and required-materialization
errors.
## Data and contracts
`repositories.pull_before_worktree` remains a persisted user policy. No schema
change is necessary.
The preparation fields have these contracts:
| Field | Contract |
| --- | --- |
| `PullBeforeWorktree` | Kandev attempts remote refresh before local worktree creation. |
| `RemoteSyncHandled` | The configured remote route completed a refresh for this checkout. |
| `RefreshRepository` | An optional provider refresh for worktree materialization. |
`PullBeforeWorktree` is not a universal admission gate. Local-ref availability
determines whether a failed refresh is recoverable.
The task repository's stored base remains the branch-only comparison target
and offline launch fallback. An explicit repository-qualified target takes
precedence under the proposed amendment below. The GitHub task-PR row tracks the provider observation;
polling propagates a changed non-empty base to the matching task repository.
## Pull-request base reconciliation
The executor reads the pull-request number from task-repository metadata and
the owner and repository name from the repository entity. For GitHub repository
rows with a positive pull-request number, it asks an injected resolver for the
current base before materialization. A successful non-empty result overrides
the launch request only. A missing resolver, lookup error, or empty result keeps
the stored base and does not stop launch.
Once the pull-request task reaches Git refresh, its base remains strict for
unproven failures. A proven missing remote base can use only the fallback
described below.
The GitHub polling service compares the previous `TaskPR.BaseBranch` with the
incoming non-empty base. On change, it updates the task repository whose task
and repository IDs match. When more than one association matches, the checkout
branch must also match the pull-request head. Update failures are logged and do
not fail the authoritative task-PR sync.
## Repository-qualified PR bases
This section implements criteria .15 through .19. It qualifies the branch-only
lookup and fallback rules above. Delivery is tracked in the
[fork PR base resolution package](../../../plans/fork-pr-base-resolution/plan.md).
### Identity and provider lookup
`ResolveRemoteDefaultBranch` retains its origin-default meaning. It cannot
resolve a PR base because its input contains no task or provider identity.
Do not replace `repositories.default_branch` with a PR target.
Extend the executor's `PRBaseResolver` result beyond a string. The proposed
result contains the validated PR identity, head identity, target repository,
target branch, and optional observed base OID. Reuse `models.ComparisonTarget`
for repository and branch validation. Keep the observed OID transient; it is
neither a merge-base nor a replacement for durable branch identity.
Resolve the provider namespace from the exact attachment's existing binding:
1. Use `ComparisonTarget.TargetRepository` and its change number when present.
2. For `RemoteContribution`, use its validated canonical change identity. PR URL
tasks already attach to the target repository and configure a source remote.
3. Otherwise use an exact linked `TaskPR` identity, matched to this attachment
and checkout branch. A bare PR number is not globally unique.
4. Legacy metadata-only requests can query the attached repository. Accept the
response only after its head repository and branch match the attached
checkout. For a `RemoteContribution`, compare the PR head to the validated
source repository and require the PR target repository to match the
attachment. An ambiguous or mismatched response cannot resolve a qualified
PR base. Ordinary target-attached PR links use the compatibility path below.
Never choose the first PR in a task-wide list. Validate current binding ownership
before applying a live retarget. Preserve manual comparison selections and
historical associations under the existing task-service reconciliation rules.
`githubPRBaseResolver` in `internal/backendapp/orchestrator.go` returns a
validated target and optional observed base OID. REST uses `base.sha` and
GraphQL uses `baseRefOid`. The supported `gh pr view/list --json` field set does
not include `baseRefOid`; `GHClient.GetPR` reads `.base.sha` through `gh api`
and keeps PR details usable if that best-effort OID read fails. A lookup failure
can retain a valid stored qualified target, but cannot downgrade that target to
a bare branch name. A known cross-repository lookup failure or invalid legacy
association cannot fall back to a stored branch without a qualified target;
ordinary provider outages remain eligible for the existing stored-base rule.
A stored qualified target can materialize its exact branch without a fresh
provider snapshot. The fetched commit supplies the current OID. Without a
validated target identity, a known cross-repository request remains unresolved.
Same-repository offline branch fallback retains criterion .11.
### Ordinary PR-link launch compatibility
The [PR-link fork launch repair](../../../plans/pr-link-fork-launch/plan.md)
clarifies criterion .15 for existing browser-created attachments. This amendment
is implemented. Its plan and work orders record the implementation and
verification status.
The browser PR-link path stores the target repository, PR number, checkout
branch, and base branch. It does not create a `RemoteContribution` binding.
`associatePRFromRepoInputs` links the PR asynchronously. Launch must therefore
resolve identity correctly before and after that association exists.
Base validation distinguishes these attachment forms:
- With `RemoteContribution`, the persisted source repository and head branch
remain authoritative. The provider target must match the attached repository.
- With a fork-attached checkout, the provider head must match the attached
repository and checkout branch. Existing comparison-target checks still apply.
- With an ordinary target-attached PR checkout, provider resolution must use
the attached target namespace and exact PR number. The returned target must
match that attachment, and the returned head branch must match the checkout.
The validated provider response supplies the source repository for this
launch. Missing, malformed, unrelated, or ambiguous identities are rejected.
The last form requires a positive PR number and a non-empty checkout branch.
It applies only without an explicit contribution or comparison binding.
It cannot override an explicit source binding after a mismatch. An unrelated
linked PR cannot authorize this form merely by sharing a number or branch.
`validPRBaseIdentity` must evaluate the attachment form before applying its head
repository comparison. Do not remove repository checks or accept a branch-only
match. `githubPRBaseResolver` retains provider namespace validation and exact
linked-association selection. Without a linked row, it resolves the attached
namespace directly. Multiple matching linked rows remain an error.
For a target-attached checkout, the existing PR-head fetch uses the target
repository's PR namespace. The comparison base remains that target repository's
branch. A fork-attached checkout retains qualified upstream materialization.
No contribution binding, comparison binding, credential scope, or push destination
is synthesized by this compatibility path. Provider resolution is read-only.
Retry of an unprepared legacy task uses the same validation. Valid worktree
reuse and explicit manual-base overrides retain their existing rules.
A provider outage retains the existing fallback policy. Known cross-repository
errors and cancellation must not become branch-only fallback successes.
Coverage includes ordinary PR-link metadata with and without a linked row,
explicit source mismatch, unrelated target, malformed identity, colliding PR
numbers, and two attachments where only one is valid. A real Git fixture proves
that PR head and base commits come from their respective repositories.
### Preparation and materialization
Carry the qualified target through `repoInfo`, lifecycle launch and preparation
requests, and `worktree.CreateRequest`. Audit their copy and conversion methods,
including multi-repository preparation, workspace-only creation, and recreation.
Keep checkout-head fetching separate from target-base fetching. A fork-origin
checkout must not fetch an upstream PR number from the fork's `refs/pull` namespace.
Use the validated PR host for that ref or the validated head repository/branch.
Before branch-only fallback, select the explicit target when present. Fetch only
its target branch into `ComparisonTarget.ComparisonRef()`. Resolve the resulting
commit OID and return it for Git operations. Retain repository/branch identity
for metadata and diagnostics; do not persist a hash as `BaseBranch`.
Reuse the deterministic comparison remote and collision rules from
[ADR-2026-08-19-repository-qualified-comparison-targets](../../../decisions/2026-08-19-repository-qualified-comparison-targets.md).
Extract reusable Git materialization into `internal/common/gitbase` rather than
importing agentctl process code into the backend. Keep typed consumer errors
in their existing owners. Agentctl keeps its asynchronous comparison scheduling.
The shared primitive only validates and materializes a target through an injected,
classified Git runner. It neither schedules work nor chooses fallback policy.
The remote keeps push disabled. A conflicting configured URL is an error.
Commands remain bounded and noninteractive under the existing credential route.
This repair grants no additional private-repository credential scope.
When a provider OID is supplied, verify that exact commit against the fetched
branch snapshot. A mismatch stops required preparation with a stale-observation
error. A later retry obtains fresh provider evidence; no automatic merge occurs.
Do not fetch an arbitrary unvalidated SHA or overwrite a user branch.
Remote executor materialization must receive the same qualified target before
its base-dependent Git operations. Carry the optional contract through the
runtime client and `agentctl/server/api/workspace_materialize.go`. Keep branch
labels separate from resolved refs in existing request validation. An ordinary
checkout with no qualified target uses the existing route.
Required materialization failure stops new preparation. A valid reused worktree
still follows the existing reuse policy. Background comparison failure still
leaves working-tree status usable under the platform status contract.
### Recovery and persistence
`recoverTaskLaunchBranch` currently resolves origin and calls the manual
`UpdateRepositoryBaseBranch` operation. That operation can clear a comparison
target, even when the branch name is unchanged.
Reject `retry_default` for a validated cross-repository binding before any
repository-default or task-base write. Preserve the binding and error stamp.
Use the existing bounded recovery-error envelope. Do not relabel the action
or silently treat it as permission to abandon the target. `retry_launch` can
retry the same qualified preparation. An explicit user-selected base branch
is marked in task-repository metadata and remains authoritative across provider
refreshes. Only an explicit comparison-target association clears that marker;
provider sync and recovery cannot replace the manual selection.
Ordinary `retry_default` still resolves and updates the repository default.
Recovery must check the exact attachment and current error stamp. A second
attachment or historical PR cannot supply the selected target.
No database migration is required. Existing contribution/comparison bindings
remain the durable identity. New OID observations and preparation refs remain
transient. Provider retarget persistence stays in the existing reconciler.
### Evidence and compatibility
Tests must distinguish identical branch names in two repositories with different
commits. Include a fork-only origin, a non-default PR target, linked worktrees,
provider retargeting, target fetch errors, remote collisions, and cancellation.
Include two PRs with the same number in different namespaces and a mixed task
with one valid repository and one unresolved cross-repository base.
Preserve the completed comparison-target and stacked-PR packages. Their old
verification results remain historical. New compatibility results belong to
this repair's work orders. Issue #3856 owns the separate reconciliation hint.
## Refresh policy
If `PullBeforeWorktree` is false, Kandev uses the selected local base without a
remote request.
If `PullBeforeWorktree` is true, Kandev attempts the configured refresh before
new or recreated host worktrees. A valid reusable worktree bypasses this
attempt.
The refresh route still follows the task Git credential policy. A host checkout
keeps its configured origin and transport. A provider-managed checkout keeps
its exact credential scope.
## Local fallback
Before refresh, the worktree manager verifies the selected local ref. If that
ref exists, refresh is best effort.
Authentication, network, timeout, Git, and missing-remote-ref errors return the
selected local ref. The manager emits a bounded warning and does not include
raw credential output.
The fallback does not change local or remote refs. It does not push the local
branch.
Pull-request base refresh does not use this local fallback. It remains strict
for unproven failures and uses only the verified missing-base fallback below.
## Base-ref selection
After a successful fetch, the worktree manager compares local base `L` and
remote base `R`:
| Relationship | Start ref | Result |
| --- | --- | --- |
| `R` contains `L` | `R` | The worktree includes current remote commits. |
| `L` contains `R` | `L` | The worktree preserves local-only commits. |
| Refs diverge | `L` | The worktree preserves the selected local history and shows a warning. |
| Ancestry is unknown | `L` | The worktree preserves the selected local history and shows a warning. |
The manager never resets, rebases, merges, removes, or hides either ref during
selection.
## Required materialization
Remote access is required when Kandev cannot verify a usable local base. This
case includes an explicit remote-only ref and an executor that must clone the
repository.
If materialization fails, lifecycle stops launch. The task error identifies the
repository and uses a bounded failure class.
Provider selection still follows the existing Git credential policy. A managed
HTTPS route must not replace an executor-owned SSH route.
## Missing-base fallback
A fetch error that explicitly reports a missing remote ref is the only failed
fetch eligible for fallback. When the request carries a different non-empty
fallback base, the manager fetches that branch through the same non-interactive
route and applies the normal containing-ref checks. Success completes sync,
uses the fallback ref, and records a warning naming the requested and fallback
branches.
If the fallback is absent or its refresh fails, preparation remains failed. A
missing requested or fallback remote ref uses `missing_remote_ref`; auth,
network, timeout, cancellation, and other Git failures keep their existing
fail-closed classifications.
## Empty remote
An authenticated remote with zero refs uses the marked local baseline from the
[Empty Remote Repository System Design](empty-remote-repositories.md).
This path creates no remote ref during launch. Publication remains an explicit
user action.
## Multi-repository launch
Each repository resolves its base independently before agent startup. A local
fallback counts as a prepared repository.
If one repository needs remote materialization and has no usable base, the
whole task stops before agent startup. The error names that repository.
## Failure and recovery
- A refresh error with a valid local base produces a warning and continues.
- A missing local and remote base produces a launch error.
- Caller cancellation stops preparation without a fallback worktree.
- A retry repeats remote materialization only when the task still needs it.
- A valid reused worktree does not refresh or materialize its base again.
- A pull-request base refresh failure stops preparation unless the missing
remote ref has a separately refreshed configured fallback.
- A pull-request fallback reports the requested and selected branches and uses
`missing_remote_ref` when no usable fallback exists.
## Security
Git stays non-interactive. Host refresh uses the host credential route.
Provider refresh uses exact task and repository scope.
Warnings contain a failure class, branch name, and repository identity. They do
not contain tokens, credential-helper output, or secret URLs.
## Observability
The existing sync progress callback reports a running event and then either a
completed event or a failed event. A failed event contains the bounded failure
class and repository identity. A successful missing-base fallback reports a
completed event and records the branch substitution warning on the worktree.
Local fallback reports completion with a warning, not a launch error.
Structured logs record repository identity, refresh route, failure class, and
selected fallback ref. Logs exclude credential material and raw remote URLs.
### Safe refresh diagnostics
AC .14 extends the existing observability contract. `manager_git.go` captures
combined command output, but failure paths retain only coarse reasons or an
exit status. Inspect that output in memory and emit fixed diagnostic values.
Do not append raw output to logs, returned errors, or progress events.
Keep the existing policy `reason` separate from a diagnostic-only
`diagnostic_code`. Add an allow-listed classifier in
`internal/worktree/refresh_diagnostics.go`. It recognizes authentication,
SSH public-key rejection, SSH host verification, DNS, connection failure,
TLS verification, missing remote ref, non-fast-forward rejection, repository
access failure, and lock contention. Unknown output maps to `unknown`.
Context cancellation and execution timeout take precedence over text matches.
Each code maps to a fixed English explanation. No explanation includes
substrings from Git output. Repository access failure does not assert that
credentials are invalid, and lock contention does not assert a stale lock.
Emit one structured diagnostic per failed fetch or pull with `operation`,
`repository_path`, `branch`, `reason`, `diagnostic_code`, `detail`, and an exit
code when available. The local repository path identifies the checkout without
reading its remote URL. Existing failure or fallback logs can carry these fields
instead of adding a duplicate diagnostic. Cover both required and best-effort
paths, including a failed configured fallback branch.
Classification is observational. Do not feed new diagnostic categories into
missing-base fallback eligibility or alter `syncFailureCause` identities.
Preserve context errors and current progress/error payloads. Correct the
`syncFailureCause` comment that implies raw output reaches internal logs.
No new logging configuration, subprocess, retry, or persistence is required.
Delivery: [Recovery diagnostic fixes](../../../plans/recovery-diagnostics/plan.md).
## Related decisions
- [Local Worktree Refresh Is Best Effort](../../../decisions/2026-08-31-local-worktree-refresh-best-effort.md)
- [Required Worktree Refresh Fails Closed](../../../decisions/2026-08-25-required-worktree-refresh-fails-closed.md)
- [Separate GitHub Automation From Task Git Credential Policy](../../../decisions/2026-07-27-task-git-credential-policy.md)
- [Provider-Neutral Git Credential Broker](../../../decisions/2026-07-31-provider-neutral-git-credential-broker.md)