Skip to content

Commit effcec4

Browse files
authored
fix: close workspace transition deadlocks (#174)
1 parent c4a41b1 commit effcec4

22 files changed

Lines changed: 1019 additions & 210 deletions

IMPORT_PROVENANCE.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -259,7 +259,6 @@
259259
"docs/safety.md": "1a2b84e0a4b9aa6322d35d6677ff52662c306089031295692b569233cdd94d8f",
260260
"docs/troubleshooting.md": "252937c97d40197e9122e20f421f90616aecdd5ddf50cee8bdd348bc6c5caf32",
261261
"docs/validation-and-evidence.md": "e7d91ad49c6adb44784ebe7d94feceb6abd445857f9a0716f0758bf6b55296c5",
262-
"docs/why-these-steps.md": "cbe0d769db11ef15bb1dff888009378d6783776ad020e6f5139847a1dd62fa09",
263262
"install.ps1": "f48d0f26a26e806b780d10fa916c261ff9f84ab39758cf9e229f647836845e86",
264263
"install.sh": "2575f82568b76b14e72fb88de4e8af677da1e77b9cf1a085b4ddfbbd766e67f9",
265264
"labs/diagram-json/README.md": "f56a120877c8a3b10daa49c6d951481c02e98b8b8bb3f28672e9e092d97a37bb",

README.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,7 @@ Boatstack is a repository-local delivery harness.
152152
- <!-- boatstack-claim:cross-model-failures -->**Observed:** benchmark runs exposed failures in protocol handling, context, verification, and recovery — not only model capability.
153153
- <!-- boatstack-claim:lower-cost-outcomes -->**Being evaluated:** whether this improves product quality, cost, or delivery time with lower-cost models.
154154

155-
This does not mean every model performs equally. [See the evidence and paired evaluation design](docs/why-these-steps.md#model-choice-and-budget).
155+
This does not mean every model performs equally. [See the evidence and evaluation design](docs/research-and-design.md#evaluation-of-the-finished-node).
156156

157157
## Built from failures observed in real coding work
158158

@@ -165,9 +165,9 @@ These behaviors come from coding failures observed in benchmark and product work
165165
| <!-- boatstack-claim:irreversible-operations -->A failed write led to an invented reset path | Denies high-confidence destructive recovery | Hook behavior verified; outcome benefit still being evaluated |
166166
| <!-- boatstack-claim:reviewer-ready-pr -->A PR lost decisions and accepted gaps | Builds a review brief from scope, diff, and evidence | Projection and stale-preview tests |
167167
| <!-- boatstack-claim:phase-scoped-delivery -->A phased plan opened PRs during build | Gates and publishes one delivery slice at a time | Slice-state and bypass tests |
168-
| <!-- boatstack-claim:git-worktree-activation -->A worktree inherited no ignored helper | Uses its tracked pinned launcher to verify and activate the exact runtime before command dispatch | Linked-worktree, identity, and tamper tests |
168+
| <!-- boatstack-claim:git-worktree-activation -->A feature worktree lost its helper or stranded its validated plan | Verifies the pinned runtime and moves the exact planning package before approval or autonomy | Linked-worktree, identity, rollback, and plan-fingerprint tests |
169169

170-
[Read what happened, what is tested, and what remains open](docs/why-these-steps.md). The [claim record](docs/public-claims.json) keeps every material statement tied to its sources.
170+
The [claim record](docs/public-claims.json) keeps every material statement tied to its sources and tests.
171171

172172
## A small example
173173

@@ -204,7 +204,7 @@ The installer previews generated paths, verifies the platform helper, offers opt
204204

205205
**Start:** [Getting started](docs/getting-started.md) · [Files](docs/generated-files.md) · [Troubleshooting](docs/troubleshooting.md)
206206

207-
**Inspect:** [Why these steps](docs/why-these-steps.md) · [Validation and evidence](docs/validation-and-evidence.md) · [Safety](docs/safety.md)
207+
**Inspect:** [Research and design](docs/research-and-design.md) · [Validation and evidence](docs/validation-and-evidence.md) · [Safety](docs/safety.md)
208208

209209
**Go deeper:** [Coding](docs/evidence-engineered-coding.md) · [Design](docs/research-and-design.md) · [Contributing](CONTRIBUTING.md)
210210

boatstack/SKILL.md

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ To see every feature at once, run the read-only `.product-loop/boatstack flow fr
3636

3737
## Run to an explicit goal
3838

39-
For `$boatstack run --to plan|verified|pr`, `/boatstack-run`, or a natural-language run request, resolve the target from the request. When it is absent, ask once for `plan`, `verified`, or `pr`. First run the read-only `next-status --repo . --json` and `operation-status --repo . --json`. Wait for an executing operation and reconcile unknown completion before retrying. When the host supplies the plan path, enter `auto-plan` with `--plan <path>`; when no plan path is supplied, stop and ask the user for the plan to build. Return **Feature complete** only for a verified completed feature, and stop on unverified, ambiguous, stale, or invalid state. Schema-v3 `check-plan` runs the Git freshness preflight before it displays the plan fingerprint. Record the selected target with `record-autonomy --plan <plan.md> --target <target>` after all material questions are answered or every remaining question has a valid `RESOLVED_BY_POLICY` autonomy decision. Target `plan` stops after the valid reviewable plan. Targets `verified` and `pr` activate with `--autonomy <autonomy.md>` and stop if that receipt becomes stale. A failed fetch, missing remote/base, stale base, upstream drift, wrong worktree, constrained branch mismatch, incomplete journey decision, or ineligible policy decision blocks without creating authority or consuming repair budget. Never repair freshness by merging, rebasing, switching or creating a constrained delivery branch, discarding changes, force-pushing, or broadening permissions.
39+
For `$boatstack run --to plan|verified|pr`, `/boatstack-run`, or a natural-language run request, resolve the target from the request. When it is absent, ask once for `plan`, `verified`, or `pr`. First run the read-only `next-status --repo . --json` and `operation-status --repo . --json`. Wait for an executing operation and reconcile unknown completion before retrying. When the host supplies the plan path, enter `auto-plan` with `--plan <path>`; when no plan path is supplied, stop and ask the user for the plan to build. Return **Feature complete** only for a verified completed feature, and stop on unverified, ambiguous, stale, or invalid state. Schema-v3 `check-plan` runs the Git freshness preflight before it displays the plan fingerprint. When workspace management is enabled, run `workspace-cut` after the plan passes validation and continue every later command from its `destination_repository`. Only then record human approval or the selected autonomy target, so the receipt binds the final feature branch. Target `plan` stops after the valid reviewable plan. Targets `verified` and `pr` activate with `--autonomy <autonomy.md>` and stop if that receipt becomes stale. A failed fetch, missing remote/base, stale base, upstream drift, wrong worktree, constrained branch mismatch, incomplete journey decision, or ineligible policy decision blocks without creating authority or consuming repair budget. Never repair freshness by merging, rebasing, switching or creating a constrained delivery branch, discarding changes, force-pushing, or broadening permissions.
4040

4141
After preflight, repeatedly run `next-status --repo . --json`, execute only its verified next operation, verify the resulting repository state, and resolve again. Continue across all declared slices until the selected target is reached. A policy receipt may resolve only a non-material, within-spec, reversible choice with one recommendation, repository evidence, no protected impact, and a runnable oracle. Record it as `RESOLVED_BY_POLICY`, never `ANSWERED`. Any failed or unknown condition pauses for the human. Target `verified` stops after current test and review evidence passes. Target `pr` supplies scoped authority for one normal open or update action recorded in `autonomy.md`; after the exact preview is revalidated, call `publish-pr --autonomy <autonomy.md>` without asking for `o` or `u`. A changed plan, repository, branch, PR action, preview, evidence, or target invalidates that path. Same-intent test/review failures may be repaired for at most three complete cycles per active slice. Stop on amendments, ambiguity, safety failures, stale evidence, unsupported recovery, branch mismatch, or exhausted repairs. Never force-push, merge, deploy, or execute a foreign program.
4242

@@ -135,10 +135,11 @@ Treat repository-owned product context as canonical. Do not require it to be mig
135135
```
136136

137137
2. Present the draft spec, plan, open decisions, accepted assumptions, gaps, risks, validation provenance, `PLAN_FINGERPRINT`, and `READINESS_FINGERPRINT` in a reviewable form. A schema-v3 plan must decide `journey_evidence`: `relevant` with complete typed runnable oracles, or `not_relevant` with a reason.
138-
3. When `workflow.human_plan_approval` is true, ask the developer to approve it or request changes and end with: Reply `a` to approve. When false, state that Build will create a policy-activation lock and do not imply human approval.
139-
4. On changes, return to `auto-plan`, preserve the feedback in the question ledger, and issue a new draft.
140-
5. When human approval is enabled, invoke `.product-loop/boatstack record-approval` with the plan, named human, RFC3339 timestamp, and exact fingerprint. When disabled, create no `approval.md`.
141-
6. End in Plan mode and tell the developer the feature is authorized for the host's normal Build transition. Do not compile tasks, create a lock, request Agent mode merely to write a file, or edit product code.
138+
3. When workspace management is enabled, run `workspace-cut` with this feature. Continue from the returned `destination_repository`. The validated plan fingerprint must remain unchanged.
139+
4. When `workflow.human_plan_approval` is true, ask the developer to approve it or request changes and end with: Reply `a` to approve. When false, state that Build will create a policy-activation lock and do not imply human approval.
140+
5. On changes, return to `auto-plan`, preserve the feedback in the question ledger, and issue a new draft.
141+
6. When human approval is enabled, invoke `.product-loop/boatstack record-approval` with the destination plan, named human, RFC3339 timestamp, and exact fingerprint. When disabled, create no `approval.md`.
142+
7. End in Plan mode and tell the developer the feature is authorized for the host's normal Build transition. Do not compile tasks, create a lock, request Agent mode merely to write a file, or edit product code.
142143

143144
All files created or updated by `auto-plan` and `plan-gate` must be Markdown. gstack and Spec Kit may help produce those documents, but their implementation stages and non-Markdown executable state are deferred to `build`.
144145

boatstack/autonomy.go

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ const (
1414

1515
type RunTarget string
1616

17+
var autonomyRecommendedPRAction = RecommendedPRAction
18+
1719
const (
1820
RunTargetPlan RunTarget = "plan"
1921
RunTargetVerified RunTarget = "verified"
@@ -219,23 +221,23 @@ func RecordAutonomy(options AutonomyRecordOptions) (AutonomyReceipt, error) {
219221
if err != nil {
220222
return AutonomyReceipt{}, err
221223
}
224+
feature := stringValue(check.Plan["feature_id"])
225+
if workspaceEnabled(repo) && needsFreshCut(repo, feature) {
226+
return AutonomyReceipt{}, fmt.Errorf("autonomy requires the feature workspace; run workspace-cut --repo %s --feature %s and continue from destination_repository", repo, feature)
227+
}
222228
branch, err := gitCommand(repo, "rev-parse", "--abbrev-ref", "HEAD")
223229
branch = strings.TrimSpace(branch)
224230
if err != nil || branch == "" || branch == "HEAD" {
225231
return AutonomyReceipt{}, fmt.Errorf("autonomy requires an identifiable current branch")
226232
}
227233
issuingBranch := branch
228-
feature := stringValue(check.Plan["feature_id"])
229-
if workspaceEnabled(repo) && needsFreshCut(repo, feature) {
230-
branch = branchForFeature(feature)
231-
}
232234
repository, err := gitCommand(repo, "remote", "get-url", "origin")
233235
if err != nil {
234236
return AutonomyReceipt{}, fmt.Errorf("autonomy requires an origin repository identity")
235237
}
236238
action := ""
237239
if target == RunTargetPR {
238-
action, _, err = RecommendedPRAction(repo)
240+
action, _, err = autonomyRecommendedPRAction(repo)
239241
if err != nil {
240242
return AutonomyReceipt{}, fmt.Errorf("PR target requires a stable open or update action: %w", err)
241243
}

boatstack/autonomy_conformance_test.go

Lines changed: 4 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -59,28 +59,13 @@ func TestAutonomyReceiptOverridesHumanPlanGateOnlyForExactPlan(t *testing.T) {
5959
}
6060
}
6161

62-
// control-law: pre-cut-policy-authority-binds-the-future-managed-branch
63-
func TestAutonomyReceiptBindsFreshWorkspaceBranch(t *testing.T) {
62+
// control-law: autonomy-receipt-binds-policy-activation-to-plan-repository-and-branch
63+
func TestAutonomyReceiptRequiresFreshWorkspaceBranch(t *testing.T) {
6464
root := workspaceRepo(t, defaultWorkspace())
6565
runGit(t, root, "remote", "add", "origin", "https://example.invalid/operatorstack/example.git")
6666
_, _, planPath := writePlanInputs(t, root, true)
67-
receipt, err := RecordAutonomy(AutonomyRecordOptions{Repo: root, PlanPath: planPath, Target: RunTargetVerified})
68-
if err != nil {
69-
t.Fatal(err)
70-
}
71-
if receipt.IssuingBranch != "main" || receipt.Branch != "feat/feature-one" {
72-
t.Fatalf("receipt branches = issuing %q target %q", receipt.IssuingBranch, receipt.Branch)
73-
}
74-
check, err := CheckPlan(planPath)
75-
if err != nil {
76-
t.Fatal(err)
77-
}
78-
path := filepath.Join(filepath.Dir(planPath), "autonomy.md")
79-
if _, err := CheckAutonomyReceiptForPlanning(path, check, root, RunTargetPlan); err != nil {
80-
t.Fatalf("pre-cut planning check: %v", err)
81-
}
82-
if _, err := CheckAutonomyReceipt(path, check, root, RunTargetVerified, ""); err == nil || !strings.Contains(err.Error(), "branch identity changed") {
83-
t.Fatalf("activation on issuing branch should fail, got %v", err)
67+
if _, err := RecordAutonomy(AutonomyRecordOptions{Repo: root, PlanPath: planPath, Target: RunTargetVerified}); err == nil || !strings.Contains(err.Error(), "workspace-cut") {
68+
t.Fatalf("pre-cut autonomy should name the workspace transition, got %v", err)
8469
}
8570
}
8671

boatstack/detached.go

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,85 @@ func saveRegistry(stateRoot string, registry detachedRegistry) error {
207207
return os.WriteFile(registryPath(stateRoot), raw, 0o644)
208208
}
209209

210+
// registerDetachedWorkspaceAlias binds another worktree path of the same Git
211+
// repository to the existing detached controller. It never creates new
212+
// authority: origin, initial history, repository id, and Git common directory
213+
// must match the already-verified source binding.
214+
func registerDetachedWorkspaceAlias(sourceRepo, destinationRepo string) (bool, error) {
215+
source, ok, err := detachedContextFor(sourceRepo)
216+
if err != nil || !ok {
217+
if err == nil {
218+
err = fmt.Errorf("source repository is not attached in detached mode")
219+
}
220+
return false, err
221+
}
222+
destination, err := repoIdentity(destinationRepo)
223+
if err != nil {
224+
return false, err
225+
}
226+
if destination.RepoID != source.RepoID {
227+
return false, fmt.Errorf("destination repository identity does not match the detached controller")
228+
}
229+
binding, err := loadBinding(filepath.Dir(filepath.Dir(source.controlRoot)), source.RepoID)
230+
if err != nil {
231+
// controlRoot is <state>/repositories/<repoID>; resolve the state root
232+
// directly when a non-standard layout makes the derivation ambiguous.
233+
stateRoot, rootErr := detachedStateRoot()
234+
if rootErr != nil {
235+
return false, rootErr
236+
}
237+
binding, err = loadBinding(stateRoot, source.RepoID)
238+
}
239+
if err != nil || !bindingMatchesIdentity(binding, destination) || binding.GitCommonIdentity != destination.GitCommonIdentity {
240+
return false, fmt.Errorf("destination worktree does not match the detached binding")
241+
}
242+
stateRoot, err := detachedStateRoot()
243+
if err != nil {
244+
return false, err
245+
}
246+
registry, err := loadRegistry(stateRoot)
247+
if err != nil {
248+
return false, err
249+
}
250+
root := destination.CanonicalRepoPath
251+
if existing, found := registry.Repositories[root]; found {
252+
if existing != source.RepoID {
253+
return false, fmt.Errorf("destination worktree is already bound to another controller")
254+
}
255+
return false, nil
256+
}
257+
registry.Repositories[root] = source.RepoID
258+
if err := saveRegistry(stateRoot, registry); err != nil {
259+
return false, err
260+
}
261+
invalidateWorkspaceCache()
262+
return true, nil
263+
}
264+
265+
func unregisterDetachedWorkspaceAlias(repo string) error {
266+
root, err := ResolveRepository(repo)
267+
if err != nil {
268+
return err
269+
}
270+
stateRoot, err := detachedStateRoot()
271+
if err != nil {
272+
return err
273+
}
274+
registry, err := loadRegistry(stateRoot)
275+
if err != nil {
276+
return err
277+
}
278+
if _, found := registry.Repositories[root]; !found {
279+
return nil
280+
}
281+
delete(registry.Repositories, root)
282+
if err := saveRegistry(stateRoot, registry); err != nil {
283+
return err
284+
}
285+
invalidateWorkspaceCache()
286+
return nil
287+
}
288+
210289
func loadBinding(stateRoot, repoID string) (DetachedBinding, error) {
211290
var binding DetachedBinding
212291
raw, err := os.ReadFile(bindingPath(stateRoot, repoID))

0 commit comments

Comments
 (0)