Skip to content
Merged
58 changes: 58 additions & 0 deletions internal/cli/state_commit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,64 @@ func TestStateCommitHaltsOnSameEntityConflict(t *testing.T) {
}
}

// TestStateCommitHaltStderrCarriesRemediationAndPeerCommit pins AC-2 (D1): the
// exit-3 HALT stderr carries the FO's next-action line, the never-force/never-
// auto-resolve line, and the peer commit that survived the aborted rebase — the
// remediation the resident prose used to own, now emitted at fire time. The peer
// commit is A's pushed HEAD sha (the pull's fetch phase updates origin/{branch}
// before the rebase conflicts; abort does not touch it).
func TestStateCommitHaltStderrCarriesRemediationAndPeerCommit(t *testing.T) {
_, workflowA, workflowB, _ := twoHostStateWorkflow(t)
checkoutA := filepath.Join(workflowA, ".spacedock-state")
hostA := filepath.Dir(filepath.Dir(workflowA))

writeEntity(t, workflowA, "first-task", "---\nstatus: implementation\n---\n# First Task (A)\n")
if code, _, errOut := runStateCommitCmd(t, hostA, workflowA, "first-task", "-m", "A: -> implementation"); code != 0 {
t.Fatalf("A's commit should succeed (exit 0); got exit=%d stderr=%q", code, errOut)
}
peerSHA := strings.TrimSpace(git(t, checkoutA, "rev-parse", "--short", "HEAD"))

writeEntity(t, workflowB, "first-task", "---\nstatus: review\n---\n# First Task (B)\n")
hostB := filepath.Dir(filepath.Dir(workflowB))
code, _, errOut := runStateCommitCmd(t, hostB, workflowB, "first-task", "-m", "B: -> review")
if code != 3 {
t.Fatalf("same-entity conflict must HALT with exit 3; got exit=%d stderr=%q", code, errOut)
}
if !strings.Contains(errOut, "Peer commit: "+peerSHA) {
t.Fatalf("HALT stderr should name the peer commit %q, got:\n%s", peerSHA, errOut)
}
if !strings.Contains(errOut, "Next: HALT dispatch — do not dispatch against this state tree. Surface the conflicting path(s) and peer commit to the operator and stop.") {
t.Fatalf("HALT stderr should name the FO's next action, got:\n%s", errOut)
}
if !strings.Contains(errOut, "Never `git push --force`/`--force-with-lease`; never re-run with `-X ours`/`-X theirs`; never discard either side.") {
t.Fatalf("HALT stderr should carry the never-force/never-auto-resolve line, got:\n%s", errOut)
}
}

// TestStateCommitHaltJSONCarriesPeerCommit pins AC-2's --json requirement: the
// halt envelope carries peer_commit alongside the existing conflicting_paths.
func TestStateCommitHaltJSONCarriesPeerCommit(t *testing.T) {
_, workflowA, workflowB, _ := twoHostStateWorkflow(t)
checkoutA := filepath.Join(workflowA, ".spacedock-state")
hostA := filepath.Dir(filepath.Dir(workflowA))

writeEntity(t, workflowA, "first-task", "---\nstatus: implementation\n---\n# First Task (A)\n")
if code, _, errOut := runStateCommitCmd(t, hostA, workflowA, "first-task", "-m", "A: -> implementation"); code != 0 {
t.Fatalf("A's commit should succeed (exit 0); got exit=%d stderr=%q", code, errOut)
}
peerSHA := strings.TrimSpace(git(t, checkoutA, "rev-parse", "--short", "HEAD"))

writeEntity(t, workflowB, "first-task", "---\nstatus: review\n---\n# First Task (B)\n")
hostB := filepath.Dir(filepath.Dir(workflowB))
code, stdout, errOut := runStateCommitCmd(t, hostB, workflowB, "first-task", "-m", "B: -> review", "--json")
if code != 3 {
t.Fatalf("same-entity conflict must HALT with exit 3; got exit=%d stderr=%q", code, errOut)
}
if !strings.Contains(stdout, `"peer_commit": "`+peerSHA+`"`) {
t.Fatalf("--json halt envelope should carry peer_commit=%q, got:\n%s", peerSHA, stdout)
}
}

// TestStateCommitIsPathScoped pins AC-2: a sibling dirty/untracked file in the
// state checkout is NOT swept into the commit (the verb stages exactly the entity,
// never `add -A`). This is the w4 2/3 `cd && git add -A` drift the verb deletes.
Expand Down
43 changes: 42 additions & 1 deletion internal/cli/state_ready_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,41 @@ func TestStateReadyHaltsOnBootConflict(t *testing.T) {
}
}

// TestStateReadyHaltStderrCarriesRemediationAndPeerCommit pins AC-2 (D1) for the
// boot-conflict HALT path: identical remediation to `state commit`'s exit-3 halt
// — the peer commit, the FO's next-action line, and the never-force line.
func TestStateReadyHaltStderrCarriesRemediationAndPeerCommit(t *testing.T) {
_, workflowA, workflowB, _ := twoHostStateWorkflow(t)
checkoutA := filepath.Join(workflowA, ".spacedock-state")
checkoutB := filepath.Join(workflowB, ".spacedock-state")
hostA := filepath.Dir(filepath.Dir(workflowA))
hostB := filepath.Dir(filepath.Dir(workflowB))

writeEntity(t, workflowA, "first-task", "---\nstatus: implementation\n---\n# First Task (A)\n")
if code, _, errOut := runStateCommitCmd(t, hostA, workflowA, "first-task", "-m", "A: -> implementation"); code != 0 {
t.Fatalf("A's commit should succeed; exit=%d stderr=%q", code, errOut)
}
peerSHA := strings.TrimSpace(git(t, checkoutA, "rev-parse", "--short", "HEAD"))

writeEntity(t, workflowB, "first-task", "---\nstatus: review\n---\n# First Task (B)\n")
git(t, checkoutB, "add", "first-task.md")
git(t, checkoutB, "commit", "-q", "-m", "B: -> review", "--", "first-task.md")

code, _, errOut := runStateReadyCmd(t, hostB, workflowB)
if code != 3 {
t.Fatalf("same-entity boot conflict must HALT with exit 3; got exit=%d stderr=%q", code, errOut)
}
if !strings.Contains(errOut, "Peer commit: "+peerSHA) {
t.Fatalf("ready HALT stderr should name the peer commit %q, got:\n%s", peerSHA, errOut)
}
if !strings.Contains(errOut, "Next: HALT dispatch — do not dispatch against this state tree. Surface the conflicting path(s) and peer commit to the operator and stop.") {
t.Fatalf("ready HALT stderr should name the FO's next action, got:\n%s", errOut)
}
if !strings.Contains(errOut, "Never `git push --force`/`--force-with-lease`; never re-run with `-X ours`/`-X theirs`; never discard either side.") {
t.Fatalf("ready HALT stderr should carry the never-force/never-auto-resolve line, got:\n%s", errOut)
}
}

// TestStateReadyInlineNoOp pins AC-6 inline case: an inline workflow is a clean
// no-op (exit 0, nothing to sync, no git network op).
func TestStateReadyInlineNoOp(t *testing.T) {
Expand Down Expand Up @@ -136,11 +171,17 @@ func TestStateReadyResumesAbsentCheckout(t *testing.T) {
if _, err := os.Stat(freshState); !os.IsNotExist(err) {
t.Fatalf("precondition: fresh clone should NOT yet have the state checkout (err=%v)", err)
}
code, _, errOut := runStateReadyCmd(t, fresh, freshWorkflow)
code, stdout, errOut := runStateReadyCmd(t, fresh, freshWorkflow)
if code != 0 {
t.Fatalf("state ready on an absent checkout should resume it (exit 0); got exit=%d stderr=%q", code, errOut)
}
if _, err := os.Stat(freshState); err != nil {
t.Fatalf("state ready should have resumed the absent checkout: %v", err)
}
// D1(c): the resume path carries the re-boot-after-resume sequencing the
// «state.ensure-ready» prose used to own — the FO must re-invoke the boot read
// before the greet since the checkout was just linked.
if !strings.Contains(stdout, "checkout resumed — re-run `spacedock status --boot` before the greet.") {
t.Fatalf("resumed checkout should print the re-boot-before-greet line; stdout:\n%s", stdout)
}
}
36 changes: 35 additions & 1 deletion internal/cli/state_sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ type syncResult struct {
Result string `json:"result"`
StateBranch string `json:"state_branch,omitempty"`
ConflictingPaths []string `json:"conflicting_paths,omitempty"`
PeerCommit string `json:"peer_commit,omitempty"`
Reason string `json:"reason"`
}

Expand Down Expand Up @@ -156,6 +157,13 @@ func runStateReady(ctx context.Context, args []string, env []string, dir string,
if code := runStateInit(ctx, []string{"--workflow-dir", workflowDir}, env, dir, stdout, stderr); code != 0 {
return code
}
// The re-boot-after-resume sequencing the «state.ensure-ready» prose used to
// own: a just-linked checkout means the boot read the FO already did (if any)
// predates the entity dir existing, so it must re-read before greeting.
// Prose-only (not --json) — it is FO guidance, not part of the result envelope.
if !jsonOut {
fmt.Fprintln(stdout, "checkout resumed — re-run `spacedock status --boot` before the greet.")
}
}

// No origin → ready, local-only (no network integration to do).
Expand Down Expand Up @@ -200,6 +208,10 @@ func runStateSweep(ctx context.Context, args []string, env []string, dir string,
// code is the enforcement: a caller cannot proceed on an unmerged tree.
func haltOnConflict(stdout, stderr io.Writer, jsonOut bool, command, slug, branch, checkout, entityPath, rebaseOut string) int {
conflicting := conflictingPaths(checkout)
// The peer commit that survived: the pull's fetch phase already updated
// origin/{branch} before the rebase conflicted, and --abort does not touch
// that ref, so this resolves network-free (spiked in ideation).
peerCommit := peerCommitSHA(checkout, branch)
// Restore a clean tree so the next operation starts fresh. abort failure is
// surfaced but the exit is still the halt.
runGit(checkout, "rebase", "--abort")
Expand All @@ -209,14 +221,36 @@ func haltOnConflict(stdout, stderr io.Writer, jsonOut bool, command, slug, branc
}
fmt.Fprintf(stderr, "spacedock %s: HALT — same-entity rebase conflict on %s.\n", command, branch)
fmt.Fprintf(stderr, "Conflicting path(s): %s\n", strings.Join(conflicting, ", "))
fmt.Fprintf(stderr, "The rebase was aborted (checkout left clean) and nothing was force-pushed; a peer's edit is preserved on origin. Manual intervention is required.\n")
if peerCommit != "" {
fmt.Fprintf(stderr, "Peer commit: %s (origin/%s)\n", peerCommit, branch)
}
fmt.Fprintf(stderr, "The rebase was aborted (checkout left clean) and nothing was force-pushed; a peer's edit is preserved on origin.\n")
fmt.Fprintf(stderr, "Next: HALT dispatch — do not dispatch against this state tree. Surface the conflicting path(s) and peer commit to the operator and stop.\n")
fmt.Fprintf(stderr, "Never `git push --force`/`--force-with-lease`; never re-run with `-X ours`/`-X theirs`; never discard either side.\n")
return emitSync(stdout, jsonOut, syncResult{
Command: command, Slug: slug, Result: "halted", StateBranch: branch,
ConflictingPaths: conflicting,
PeerCommit: peerCommit,
Reason: fmt.Sprintf("HALT: same-entity rebase conflict on %s — rebase aborted, nothing force-pushed, manual intervention required.", strings.Join(conflicting, ", ")),
}, 3)
}

// peerCommitSHA resolves the peer's pushed commit on origin/{branch} — the edit
// preserved when this side's rebase conflicted. Runs BEFORE `rebase --abort`
// clears the conflict, though the ref itself does not depend on abort timing (the
// pull's fetch phase already updated it). Returns "" on any git failure rather
// than surfacing an error for what is purely diagnostic context.
func peerCommitSHA(checkout, branch string) string {
if branch == "" {
return ""
}
ok, out := runGit(checkout, "rev-parse", "--short", "origin/"+branch)
if !ok {
return ""
}
return strings.TrimSpace(out)
}

// commitEntityPathScoped stages and commits exactly entityPath (never `add -A`),
// retrying the staging on index.lock contention. It returns (true, "") for a
// clean no-op (nothing staged for the entity → success), (true, output) for a
Expand Down
77 changes: 64 additions & 13 deletions internal/dispatch/reconcile.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,20 +89,19 @@ func GhRunnerExec(prRef string) (string, error) { return ghRunnerExec(prRef) }
// passes gitRunnerExec.
type gitRunner func(dir string, args ...string) (string, error)

// ghRunnerExec calls `gh pr view PR --json state` and extracts the state field.
// ghRunnerExec calls `gh pr view PR --json state --jq .state` and returns the
// trimmed, uppercased state. This mirrors boot.go's PR_STATE probe invocation
// exactly (same flags, same leading-"#" trim, same --jq extraction) rather than
// parsing the `--json state` envelope as a second JSON layer — the two probes
// must agree on what "gh can answer" means, since they run against the same `gh`
// and the FO trusts both.
func ghRunnerExec(prRef string) (string, error) {
cmd := exec.Command("gh", "pr", "view", prRef, "--json", "state")
out, err := cmd.Output()
pr := strings.TrimPrefix(prRef, "#")
out, err := exec.Command("gh", "pr", "view", pr, "--json", "state", "--jq", ".state").Output()
if err != nil {
return "", err
}
var obj struct {
State string `json:"state"`
}
if err := json.Unmarshal(out, &obj); err != nil {
return "", err
}
return obj.State, nil
return strings.ToUpper(strings.TrimSpace(string(out))), nil
}

// gitRunnerExec runs git with the given args under -C dir and returns trimmed
Expand Down Expand Up @@ -666,6 +665,8 @@ type sweepResult struct {
StateBranch string `json:"state_branch,omitempty"`
Swept []sweptEntity `json:"swept"`
Reason string `json:"reason"`
Gh string `json:"gh,omitempty"`
Next string `json:"next,omitempty"`
}

// Sweep is the read-only `state sweep` computation: the entities whose code PR has
Expand All @@ -675,6 +676,12 @@ type sweepResult struct {
// injected so tests pin a deterministic merged-state; production passes
// GhRunnerExec. An inline (single-root) workflow sweeps its own dir. Exit 0 the
// sweep ran (set may be empty or populated); 1 setup failure (no README/state).
//
// classC itself stays best-effort — a gh error silently skips that entity, so the
// idle hook never blows up on a transient failure. Sweep wraps the injected probe
// to COUNT calls and errors around that unchanged behavior: when every PR-pending
// entity's probe errored, "0 entity(ies)" would be indistinguishable from a real
// empty sweep, so Sweep reports merge state UNKNOWN instead (D2).
func Sweep(workflowDir string, gh GhRunner, jsonOut bool, stdout, stderr io.Writer) int {
if info, err := os.Stat(workflowDir); err != nil || !info.IsDir() {
fmt.Fprintf(stderr, "spacedock state sweep: workflow directory not found: %s\n", workflowDir)
Expand All @@ -685,30 +692,74 @@ func Sweep(workflowDir string, gh GhRunner, jsonOut bool, stdout, stderr io.Writ
stateRoot = workflowDir
}
active := loadEntityFrontmatter(activeEntityDir(stateRoot))
drift := classC(active, gh)

probeTotal, probeErrs := 0, 0
counting := func(prRef string) (string, error) {
probeTotal++
state, err := gh(prRef)
if err != nil {
probeErrs++
}
return state, err
}
drift := classC(active, counting)

swept := make([]sweptEntity, 0, len(drift))
for _, d := range drift {
swept = append(swept, sweptEntity{Slug: d.Slug, PR: d.PR, Reason: d.Reason})
}
branch, _ := status.StateBranch(workflowDir)
reason := fmt.Sprintf("%d entity(ies) merged but not yet terminalized.", len(swept))

var reason, ghField, next string
switch {
case probeTotal > 0 && probeErrs == probeTotal:
reason = "merge state UNKNOWN — gh unavailable; sweep skipped, not empty."
ghField = "unavailable"
case len(swept) > 0:
reason = fmt.Sprintf("%d entity(ies) merged but not yet terminalized.", len(swept))
next = sweepNextStep(workflowDir)
default:
reason = fmt.Sprintf("%d entity(ies) merged but not yet terminalized.", len(swept))
}

if jsonOut {
enc := json.NewEncoder(stdout)
enc.SetIndent("", " ")
enc.Encode(sweepResult{
Command: "state sweep", StateBranch: branch, Swept: swept, Reason: reason,
Command: "state sweep", StateBranch: branch, Swept: swept, Reason: reason, Gh: ghField, Next: next,
})
return 0
}
fmt.Fprintln(stdout, reason)
if next != "" {
fmt.Fprintln(stdout, next)
}
for _, s := range swept {
fmt.Fprintf(stdout, " %s (PR %s): %s\n", s.Slug, s.PR, s.Reason)
}
return 0
}

// sweepNextStep names the FO's next action for a non-empty sweep: the registered
// startup-hook mod file(s) to advance each entity per, from the same hookPoint ->
// mod-name scan the boot MODS-REPORT uses (status.ScanMods). It points at the mod
// FILE, never a procedure — the shipped mods/pr-merge.md advances an entity
// directly while a local per-workflow pr-merge mod can delegate to sentinel +
// merge guard, and the binary has no way to know which one applies without
// picking a side. No startup mod registered falls back to a generic _mods/
// pointer.
func sweepNextStep(workflowDir string) string {
hooks := status.ScanMods(workflowDir)["startup"]
if len(hooks) == 0 {
return "next: advance each per the workflow's startup-hook advancement (_mods/)."
}
paths := make([]string, len(hooks))
for i, h := range hooks {
paths[i] = fmt.Sprintf("_mods/%s.md", h)
}
return fmt.Sprintf("next: advance each per the workflow's startup-hook advancement (%s).", strings.Join(paths, ", "))
}

// classD flags worktrees whose branch HEAD is behind origin/{trunk}. The remedy
// is ownership-gated: a `pull --rebase` is prescribed ONLY when the worktree's
// entity slug is in `owned` — the set of slugs the CURRENT trusted roster's
Expand Down
Loading
Loading