From de61c1baf14c2a3aaaf3e8f7f618b61e39ad0dbd Mon Sep 17 00:00:00 2001 From: CL Kao Date: Fri, 28 Aug 2026 17:28:15 -0700 Subject: [PATCH 1/5] test: right-size recent proof scaffolding --- .github/workflows/runtime-live-e2e.yml | 4 +- docs/dev/README.md | 3 +- docs/runtime-live-ci-registry.md | 16 -- internal/cli/gate_test.go | 51 ++--- internal/cli/terminal_consume_test.go | 18 -- .../dispatch/build_json_ergonomics_test.go | 2 +- .../build_stage_report_protocol_test.go | 68 ------- .../ensigncycle/claude_live_runner_test.go | 49 ++--- .../ensigncycle/codex_live_runner_test.go | 8 +- internal/ensigncycle/live_test.go | 59 +----- .../pi_evidence_grade_impl_test.go | 21 --- internal/ensigncycle/pi_live_controls_test.go | 15 -- internal/ensigncycle/pi_live_runner_test.go | 2 +- .../pi_nonself_describing_build_test.go | 59 ------ .../pi_nonself_describing_live_test.go | 174 ------------------ internal/ensigncycle/team_capability_test.go | 10 +- internal/gates/round_test.go | 131 ++++--------- 17 files changed, 87 insertions(+), 603 deletions(-) delete mode 100644 internal/dispatch/build_stage_report_protocol_test.go delete mode 100644 internal/ensigncycle/pi_nonself_describing_build_test.go delete mode 100644 internal/ensigncycle/pi_nonself_describing_live_test.go diff --git a/.github/workflows/runtime-live-e2e.yml b/.github/workflows/runtime-live-e2e.yml index 1a5e4768f..effe8617a 100644 --- a/.github/workflows/runtime-live-e2e.yml +++ b/.github/workflows/runtime-live-e2e.yml @@ -80,7 +80,7 @@ jobs: run: go test ./... - name: Run deterministic live-harness controls offline - run: go test ./internal/ensigncycle -count=1 -run 'TestCleanupKeepMovingRootRetainsOnlyFailures|TestCodexLiveRunnerExecArgvEnablesMultiAgentV2|TestCodexLiveRunnerUsesSpacedockFrontDoorBeforeHostArgs|TestAssertRecordedGateHoldLogAcceptsPrepareFirstLifecycle|TestShallowBootFixtureContainsOnlyHeldGate|TestPiLiveSmokePromptRequiresExactStageReportHeading|TestPiLiveEnvDropsForeignRuntimeMarkers|TestPiLiveEnvScrubsAmbientPiSubagentMarkers|TestPiIntercomPackageRootDefaultsBesideSubagents' + run: go test ./internal/ensigncycle -count=1 -run 'TestCleanupKeepMovingRootRetainsOnlyFailures|TestCodexLiveRunnerExecArgvEnablesMultiAgentV2|TestCodexLiveRunnerUsesSpacedockFrontDoorBeforeHostArgs|TestAssertRecordedGateHoldLogAcceptsPrepareFirstLifecycle|TestShallowBootFixtureContainsOnlyHeldGate|TestPiLiveEnvDropsForeignRuntimeMarkers|TestPiLiveEnvScrubsAmbientPiSubagentMarkers|TestPiIntercomPackageRootDefaultsBesideSubagents' # Pull requests normalize to one Sonnet 5 leg. Manual dispatches choose the # routine Sonnet cadence or the separately approved Opus pre-release cadence. @@ -811,7 +811,7 @@ jobs: if: ${{ !cancelled() }} run: | set -o pipefail - gotestsum --jsonfile pi-front-door-smoke-detail.jsonl --format pkgname -- -tags live -count=1 -timeout 15m -run 'TestLivePiFrontDoorSmoke|TestLivePiNonSelfDescribingDispatch' ./internal/ensigncycle + gotestsum --jsonfile pi-front-door-smoke-detail.jsonl --format pkgname -- -tags live -count=1 -timeout 15m -run TestLivePiFrontDoorSmoke ./internal/ensigncycle - name: Upload live artifacts if: always() diff --git a/docs/dev/README.md b/docs/dev/README.md index 195a60527..9c99473b7 100644 --- a/docs/dev/README.md +++ b/docs/dev/README.md @@ -80,6 +80,7 @@ The FO contract's Operating Principles and Working Principles govern generic pro - **No prose-grep over instruction files.** A string, substring, or regex match over an instruction file the model reads (the FO/ensign contract, this README, a skill) never proves a behavioral claim — a valid paraphrase fails it and an inverted clause passes it. To settle a case, ask whether the expected value comes from outside the file under test; if it does not, the check is a tautology and is banned. A check that binds two independent values that can diverge, such as the plugin manifest's version sharing a major.minor with the binary's version, is legitimate and is not prose-grep. Captain ruling (2026-07-20, verbatim): prose-greps are one-off validation evidence, never committed tests. A grep whose output is pasted into the validation report is legitimate external evidence for that run; the same grep committed as a test is banned. A grep soundly establishes presence or absence when that existence fact is itself the claim; it never establishes what a program or an agent actually does — express that claim in a form that can be exercised. - **Evidence must be able to fail.** Each AC's cited evidence names the concrete change that would flip it — the falsifying edit. A criterion whose author cannot name that change does not count. The gate reads the falsifying change, not a pass count. +- **One proof owner per failure mode.** Reuse or modify an existing behavioral test before adding one. Add another committed check only when a distinct falsifying edit would escape the primary owner; otherwise combine or delete it. Use one-off manual validation for release provenance or external wiring that a committed test cannot reproduce truthfully. - **Detached adversarial audit (high-stakes surfaces).** Before merging a change to one of four high-stakes surfaces (the front-door launcher, the `status` mutation and guard paths, the shipped contract and scaffolding, and the CI and release machinery), run a read-only audit on a throwaway checkout. It tries to refute the validation by constructing an adversarial edit the deliverable's own tests should catch, then confirms they do. A test that stays green under a claim-breaking edit is a hole. Material findings route back through validation to implementation, and "refuted nothing material" is a valid recorded outcome. The audit also fires on AC provenance: when an AC's expected value is derived from the same package's production functions or constants, run the adversarial-edit check on it. Scope it to that provenance form; the broader equality/byte-identity form over-fires on ordinary unit tests. The two triggers are independent: the four-surface trigger runs the full audit on a throwaway checkout, while the provenance trigger fires wherever such an AC appears — including on a change routine enough to skip the full audit — and covers only that AC's adversarial-edit check, not the whole change. - **Required CI lanes are a function of the diff, not the FO's read of "relatedness."** Merging on the deterministic lanes (build/install/offline) alone is allowed ONLY when the diff provably touches nothing a live lane loads or drives. When the diff touches a file a live lane exercises — the shipped FO/ensign contract or a host adapter (`skills/**/references/**`), the dispatch/launch path, or the lane's own live tests — that lane is REQUIRED green before merge, and a flake there is grounds to re-run to green (serial, isolated), NEVER to skip, leave its deployment unapproved, or wave off as "the known flake." The path→lane mapping is the gate: a change to the Claude adapter requires `claude-live`; to a host adapter, that host's lane; to the host-neutral dispatch core, every host lane. - **Instruction-file read quarantine.** Tests do not read prompt or instruction files except in two cases. First, in `internal/contractlint`, and there only for structural checks: reference closure, frontmatter validity, structural absence, and dedup. Second, to extract a shipped runnable block and execute it against independent fixture conditions, where the oracle is the fixture's on-disk state or the block's observed output and never the file's wording — `skills/integration/survey_probe_test.go` is the reference shape. Prose-grep and prose-to-code consistency checks never substitute for running the behavior. @@ -148,7 +149,7 @@ A task moves to ideation when a pilot starts fleshing out the idea: clarify the - At least one AC must MEASURE the end-value the entity exists for, against an independent baseline that can move the wrong way (a number/delta/count/timing, a behavior, or resulting on-disk state). An AC that only asserts its mechanism shipped — "the prose updates to X", "the verb owns Y", "the section is rewritten to Z" — is end-state phrasing of a *means*; it counts only paired with the value-measuring AC it serves (cf. `trim-dispatch-adapter-prose` AC-1: cumulative line delta vs origin/main is NEGATIVE). - Every task must produce a real, checkable change (code, a fixture, on-disk state, or instruction text whose effect a separate check can confirm). If the task's only output is a decision with nothing shipped, it does not belong in this queue; record the decision in the roadmap instead. Cleanup and overhaul qualify: the change is the new code plus passing tests. - When the design rests on an unverified mechanism (a parser round-trip, a runtime handoff, an on-disk format, a tool actually supporting a flag), spike the riskiest path first (see Proof policy above) and record the result in the task body. The throwaway exercise seeds the implementation's first test. If nothing is unverified, record "no spike needed: {the proven mechanisms it relies on}". - - Test plans should state what verifies the implementation, estimated cost/complexity, and whether fixture, CLI, or live workflow tests are needed. + - Test plans should name the existing primary proof owner (or explain why none exists), the distinct falsifying edit for each additional check, estimated cost/complexity, and whether deterministic, live, or one-off manual validation is needed. - Plans should describe intended behavior at the level a future worker or validator needs to reason about it. Prefer observable behavior over implementation internals unless the task is specifically about that internal representation. - For every new mechanism in the proposed approach or test plan, name the value AC it serves, the simplest alternative considered, and why that alternative is insufficient. An enabling mechanism is not justified by proving its own internals. - Prove behavior by exercising it and observing the outcome (output bytes, exit code, resulting on-disk state, or a test feeding many inputs and asserting uniform handling): Go unit tests for parser and command behavior, golden fixtures for status output, behavior fixtures that drive the binary for command-level claims, and live workflow smoke tests only when runtime behavior is the claim. See the Proof policy above for what counts as proof and what does not. diff --git a/docs/runtime-live-ci-registry.md b/docs/runtime-live-ci-registry.md index 550e5c879..4ba9c502d 100644 --- a/docs/runtime-live-ci-registry.md +++ b/docs/runtime-live-ci-registry.md @@ -313,22 +313,6 @@ limited to the named runtime boundary. - **Fixture:** `pi/split-root-smoke` — a current-checkout Pi environment and split-root workflow with one child-dispatchable member. -### `pi-non-self-describing-dispatch` - -- **Entry point:** `TestLivePiNonSelfDescribingDispatch` -- **Lane:** `pi-live` -- **Required outcome:** A Pi worker dispatched with a checklist equal to a real - entity's acceptance criteria (no skill-path, stage-report heading, or - DONE/Summary hints) still writes a complete `## Stage Report: implementation` - with a clean state-checkout commit. The worker's only stage-report format - source is the `### Stage Report format` block the dispatch build artifact - embeds for host=pi. This is the tautology-closing lane: reverting the body - embed makes it RED while the self-describing `pi-front-door-subagent-dispatch` - lane stays green. -- **Fixture:** `pi/non-self-describing-smoke` — a split-root Pi workflow whose - implementation stage-def names only the real work (no stage-report mention), - so the embedded dispatch body block is the worker's only format source. - ## Non-gating live experiments These tests are intentionally not release evidence and are not selected by a diff --git a/internal/cli/gate_test.go b/internal/cli/gate_test.go index 9ff713609..5ab5cabb7 100644 --- a/internal/cli/gate_test.go +++ b/internal/cli/gate_test.go @@ -128,54 +128,25 @@ func TestGatePrepareCLIPrintsExactRoomBindingAndCurrentV1HelpSurface(t *testing. } func TestGatePrepareCLIPassesStateRelativeArtifactWithoutCwdJoin(t *testing.T) { - workflow, state, _ := gatePrepareCLIFixture(t) - selected := filepath.Join(state, "selected", "gate-review.md") - if err := os.MkdirAll(filepath.Dir(selected), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(selected, []byte("# Selected review\n"), 0o644); err != nil { - t.Fatal(err) - } - git(t, state, "add", "selected") - git(t, state, "commit", "-q", "-m", "committed selected artifact") - - var out, errOut bytes.Buffer - code := run(context.Background(), []string{ - "gate", "prepare", "task", - "--question", "Advance?", - "--artifact", filepath.ToSlash(filepath.Join("selected", "gate-review.md")), - "--summary", "state-relative artifact", - "--workflow-dir", workflow, - }, nil, workflow, nil, &out, &errOut, &status.NativeRunner{}, nil) - if code != 0 { - t.Fatalf("prepare exit=%d stdout=%q stderr=%q", code, out.String(), errOut.String()) - } - if !strings.Contains(out.String(), "state=open") { - t.Fatalf("prepare stdout=%q want state=open", out.String()) - } -} - -func TestGatePrepareCLIResolvesLaunchRelativeSelectedSources(t *testing.T) { - for _, flag := range []string{"--artifact", "--reference"} { - t.Run(flag, func(t *testing.T) { + for _, form := range []string{"state-relative", "launch-relative"} { + t.Run(form, func(t *testing.T) { workflow, state, artifact := gatePrepareCLIFixture(t) - selected := filepath.Join(state, "selected", "review.md") + selected := filepath.Join(state, "selected", "gate-review.md") if err := os.MkdirAll(filepath.Dir(selected), 0o755); err != nil { t.Fatal(err) } writeFile(t, selected, "# Selected review\n") git(t, state, "add", "selected") - git(t, state, "commit", "-q", "-m", "selected source") + git(t, state, "commit", "-q", "-m", "committed selected artifact") - launchDir := filepath.Dir(filepath.Dir(workflow)) - relative := filepath.ToSlash(filepath.Join("docs", "dev", ".state", "selected", "review.md")) - args := []string{"gate", "prepare", "task", "--question", "Advance?", "--artifact", artifact, - "--summary", "launch-relative source", "--workflow-dir", workflow} - if flag == "--artifact" { - args[6] = relative - } else { - args = append(args, "--reference", relative) + launchDir := workflow + artifact = filepath.ToSlash(filepath.Join("selected", "gate-review.md")) + if form == "launch-relative" { + launchDir = filepath.Dir(filepath.Dir(workflow)) + artifact = filepath.ToSlash(filepath.Join("docs", "dev", ".state", "selected", "gate-review.md")) } + args := []string{"gate", "prepare", "task", "--question", "Advance?", "--artifact", artifact, + "--summary", form + " artifact", "--workflow-dir", workflow} var out, errOut bytes.Buffer code := run(context.Background(), args, nil, launchDir, nil, &out, &errOut, &status.NativeRunner{}, nil) if code != 0 || errOut.Len() != 0 || !strings.Contains(out.String(), "state=open") { diff --git a/internal/cli/terminal_consume_test.go b/internal/cli/terminal_consume_test.go index ef1a9f7c7..30c075951 100644 --- a/internal/cli/terminal_consume_test.go +++ b/internal/cli/terminal_consume_test.go @@ -215,24 +215,6 @@ func TestConsumedNonterminalApprovalAllowsOrdinaryTerminalFields(t *testing.T) { } } -// TestConsumedNonterminalApprovalAllowsMergeGuard proves the alternate ordinary -// terminal journey uses consumed gate history without inventing new authority. -func TestConsumedNonterminalApprovalAllowsMergeGuard(t *testing.T) { - root, entity := consumedNonterminalWorkflow(t) - code, out, errOut := terminalInvoke(t, root, "merge", "guard", "task", "--verdict", "passed", "--workflow-dir", root) - if code != 0 || strings.Contains(out+errOut, "ineligible") { - t.Fatalf("merge guard exit=%d stdout=%q stderr=%q", code, out, errOut) - } - archived := filepath.Join(root, "_archive", filepath.Base(entity)) - fields := entityFields(t, archived) - if fields["status"] != "done" || fields["verdict"] != "PASSED" || strings.TrimSpace(fields["completed"]) == "" { - t.Fatalf("archived terminal fields = status:%q verdict:%q completed:%q", fields["status"], fields["verdict"], fields["completed"]) - } - if got := gateApplicationStates(t, archived); !slices.Equal(got, []string{"consumed"}) { - t.Fatalf("merge guard rewrote consumed authority: %v", got) - } -} - // TestTerminalDeliveryFailureReworkRoundTrip is AC-1's value spine: approval // recorded -> consume routes without spending (pending, approved-awaiting-merge) // -> merge guard arms; delivery fails beyond retry -> --rework supersedes diff --git a/internal/dispatch/build_json_ergonomics_test.go b/internal/dispatch/build_json_ergonomics_test.go index ee4d04997..ac86e28f9 100644 --- a/internal/dispatch/build_json_ergonomics_test.go +++ b/internal/dispatch/build_json_ergonomics_test.go @@ -240,7 +240,7 @@ func assertPiBuildOutput(t *testing.T, stdout string) { t.Fatalf("derived Pi prompt should be the read-dispatch-file form: %q", out.Prompt) } body := readDispatchBody(t, out.DispatchFilePath) - for _, want := range []string{"read this dispatch file", "/skill:ensign", "Pi subagent completion result", "Do not emit Claude team-tool calls"} { + for _, want := range []string{"read this dispatch file", "Pi subagent completion result", "Do not emit Claude team-tool calls"} { if !strings.Contains(body, want) { t.Fatalf("derived Pi dispatch body missing %q:\n%s", want, body) } diff --git a/internal/dispatch/build_stage_report_protocol_test.go b/internal/dispatch/build_stage_report_protocol_test.go deleted file mode 100644 index 40a607ac2..000000000 --- a/internal/dispatch/build_stage_report_protocol_test.go +++ /dev/null @@ -1,68 +0,0 @@ -// ABOUTME: AC-2 — the dispatch build artifact body carries the stage-report -// ABOUTME: protocol template for host=pi, and omits it for claude and codex. -package dispatch - -import ( - "os" - "path/filepath" - "strings" - "testing" -) - -func TestPiFirstActionInvokesEnsignSkill(t *testing.T) { - root := t.TempDir() - writeFile(t, filepath.Join(root, "README.md"), readmeWorktree(false)) - worktreeRel := ".worktrees/spacedock-ensign-first-action" - if err := os.MkdirAll(filepath.Join(root, worktreeRel), 0o755); err != nil { - t.Fatal(err) - } - entityPath := filepath.Join(root, "thing.md") - writeFile(t, entityPath, entityFM("Thing", "implementation", worktreeRel)) - gitInit(t, root) - - stdin := mergeStdin(map[string]any{ - "schema_version": 2, - "entity_path": entityPath, - "workflow_dir": root, - "stage": "implementation", - "checklist": []string{"- a"}, - "bare_mode": false, - "host": "pi", - }, nil) - - native := runNative(stdin, "build", "--workflow-dir", root) - if native.exit != 0 { - t.Fatalf("build exit=%d stderr=%q", native.exit, native.stderr) - } - body := readDispatchBody(t, dispatchFilePathFromStdout(t, native.stdout)) - - // The false claim that the dispatch file itself carries the ensign - // discipline entry points must be gone. - for _, banned := range []string{ - "This file contains the shared ensign discipline entry points", - } { - if strings.Contains(body, banned) { - t.Fatalf("pi First-action still carries false claim %q:\n%s", banned, body) - } - } - // The worker must be told to load the ensign skill before reading the - // dispatch file. - hasSkillLoad := strings.Contains(body, "/skill:ensign") || - strings.Contains(body, "skills/ensign/SKILL.md") - if !hasSkillLoad { - t.Fatalf("pi First-action missing ensign skill-load instruction (/skill:ensign or skills/ensign/SKILL.md):\n%s", body) - } - // The skill-load must come before the instruction to read the dispatch - // file, mirroring Claude and Codex. - skillIdx := strings.Index(body, "/skill:ensign") - if skillIdx < 0 { - skillIdx = strings.Index(body, "skills/ensign/SKILL.md") - } - readIdx := strings.Index(body, "read this dispatch file") - if readIdx < 0 { - t.Fatalf("pi First-action missing 'read this dispatch file' instruction:\n%s", body) - } - if skillIdx >= readIdx { - t.Fatalf("pi First-action: ensign skill-load must precede 'read this dispatch file' (skillIdx=%d readIdx=%d):\n%s", skillIdx, readIdx, body) - } -} diff --git a/internal/ensigncycle/claude_live_runner_test.go b/internal/ensigncycle/claude_live_runner_test.go index 945580d45..19872d1a4 100644 --- a/internal/ensigncycle/claude_live_runner_test.go +++ b/internal/ensigncycle/claude_live_runner_test.go @@ -33,8 +33,8 @@ const antiShutdownOverride = "Do not shut down your team or prepare your final " // real `spacedock claude` launch and returns the (before, after, observed) state // the shared assertions consume — the same assertions the Codex runner feeds. The // ONLY Claude-specific surface is auth/HOME isolation (isolatedClaudeEnv: clean -// HOME + OAuth benchmark-token / ANTHROPIC_API_KEY), the stable release install, -// the `spacedock claude -- -p --output-format +// HOME + OAuth benchmark-token / ANTHROPIC_API_KEY), the --plugin-dir local +// checkout candidate, the `spacedock claude -- -p --output-format // stream-json` launch, and the observed-extract: the final message comes from the // stream's result/success event (the front-door analog of Codex // --output-last-message) via extractClaudeFinalMessage. The common declarations, @@ -43,6 +43,7 @@ const antiShutdownOverride = "Do not shut down your team or prepare your final " type claudeLiveRunner struct { t *testing.T binary string + pluginDir string env []string modelName string artifactRoot string @@ -144,9 +145,10 @@ func runClaudeRecordedGateLifecycleScenario(t *testing.T, runner liveDriver, sce durableSemantic("recorded-gate-lifecycle-violation", assert(observation))) } -func newClaudeLiveRunner(t *testing.T, setupIDs ...string) claudeLiveRunner { +func newClaudeLiveRunner(t *testing.T) claudeLiveRunner { t.Helper() - binary, marketplace := stableLiveRelease(t) + binary := buildRecordedGateBinary(t) + pluginDir := livePluginDir(t) model := envOr("SPACEDOCK_LIVE_MODEL", "sonnet") // isolatedClaudeEnv resolves the credential (OAuth benchmark-token locally, @@ -156,24 +158,12 @@ func newClaudeLiveRunner(t *testing.T, setupIDs ...string) claudeLiveRunner { // binary. Both are reused verbatim from the full-cycle live test. env := isolatedClaudeEnv(t, os.Getenv("HOME")) env = withBinaryOnPath(env, binary) - env = withRecordedGateEnv(env, "SPACEDOCK_MARKETPLACE_SOURCE", marketplace) - setupID := t.Name() - if len(setupIDs) > 0 { - setupID = setupIDs[0] - } - if base, ok := envValue(env, "CLAUDE_CONFIG_DIR"); ok { - env = withClaudeConfigDir(env, filepath.Join(base, setupID)) - } homeDir, _ := envValue(env, "HOME") - setupDir := codexLiveSetupArtifactDir(claudeLiveArtifactDir(t, "claude-shared-scenarios"), setupID) - if err := os.MkdirAll(setupDir, 0o755); err != nil { - t.Fatal(err) - } - runCodexLiveCommand(t, setupDir, "stable-plugin-install.txt", "", env, binary, "install", "--host", "claude") return claudeLiveRunner{ t: t, binary: binary, + pluginDir: pluginDir, env: env, modelName: model, artifactRoot: claudeLiveArtifactDir(t, "claude-shared-scenarios"), @@ -210,7 +200,16 @@ func (r claudeLiveRunner) gradeShallowBootObservation(t *testing.T, result liveR emitShallowBootWindowMetrics(t, result.stream, r.modelName) } func (r claudeLiveRunner) prepareRecordedGate(t *testing.T) (liveDriver, func(liveResult)) { - return r, noLiveGrade + source := r.pluginDir + r.pluginDir = t.TempDir() + if err := copyTree(source, r.pluginDir); err != nil { + t.Fatal(err) + } + return r, func(result liveResult) { + if !strings.Contains(result.stream, r.pluginDir) || !strings.Contains(result.stream, "# First Officer Gate Lifecycle") { + t.Fatalf("recorded gate lifecycle did not load the copied skill body\nArtifacts: %s", result.artifactDir) + } + } } // withStubPATH returns a runner copy whose launched FO subprocess resolves a stub @@ -602,8 +601,8 @@ func runClaudeShallowBootScenario(t *testing.T, runner liveDriver, scenario shar // run launches the real `spacedock claude` front door for one shared scenario and // returns the (finalMessage, full stream) the shared assertions consume. The -// launch shape uses the installed stable package through the ordinary front door; -// every host flag (-p with the scenario prompt, +// launch shape uses the checkout candidate through --plugin-dir; every host flag +// (-p with the scenario prompt, // --permission-mode, --output-format stream-json, --verbose, --model) rides AFTER // `--` and forwards verbatim to claude. The observed source is the stream's // result/success event via extractClaudeFinalMessage — a 401/is_error result is a @@ -626,7 +625,10 @@ func (r claudeLiveRunner) run(t *testing.T, scenario sharedRuntimeScenario, work streamPath := filepath.Join(artifactDir, "claude-stream.jsonl") finalPath := filepath.Join(artifactDir, "claude-final-message.txt") - frontDoorArgs := []string{"claude", "--", + frontDoorArgs := []string{"claude", + "--plugin-dir", r.pluginDir, + "--skip-compat-check", + "--", "-p", prompt + " " + antiShutdownOverride, "--permission-mode", "bypassPermissions", "--output-format", "stream-json", @@ -635,8 +637,13 @@ func (r claudeLiveRunner) run(t *testing.T, scenario sharedRuntimeScenario, work } cmd := exec.Command(r.binary, frontDoorArgs...) cmd.Dir = workflowRoot + // Per-scenario CLAUDE_CONFIG_DIR keeps parallel scenarios isolated. cmd.Env = r.env configDir, _ := envValue(r.env, "CLAUDE_CONFIG_DIR") + if base, ok := envValue(r.env, "CLAUDE_CONFIG_DIR"); ok { + configDir = filepath.Join(base, scenario.name) + cmd.Env = withClaudeConfigDir(r.env, configDir) + } if err := seedStoredLoginCredential(configDir); err == nil { cmd.Env = withoutEnvKey(cmd.Env, "CLAUDE_CODE_OAUTH_TOKEN") } diff --git a/internal/ensigncycle/codex_live_runner_test.go b/internal/ensigncycle/codex_live_runner_test.go index ed093821c..34b1accce 100644 --- a/internal/ensigncycle/codex_live_runner_test.go +++ b/internal/ensigncycle/codex_live_runner_test.go @@ -15,7 +15,7 @@ import ( // real `spacedock codex` launch and returns the (before, after, observed) state // the shared assertions consume. Auth/HOME isolation (isolated CODEX_HOME + // minimal config plus copied auth.json / OPENAI_API_KEY), Spacedock-owned local -// stable release plugin setup, and the `--output-last-message` observed-extract are the ONLY +// checkout-candidate plugin setup, and the `--output-last-message` observed-extract are the ONLY // Codex-specific surface; the common declarations, fixtures, prompts, and assertions // are shared with the Claude runner. type codexLiveRunner struct { @@ -107,7 +107,7 @@ func newCodexLiveRunner(t *testing.T, setupIDs ...string) codexLiveRunner { t.Fatal("codex not on PATH; install Codex CLI before running the live Codex suite") } - binary, marketplace := stableLiveRelease(t) + binary := spacedockBinary(t) repo := repoRoot(t) artifactRoot := codexLiveArtifactDir(t, "codex-shared-scenarios") codexHome := newCodexLiveIsolatedHome(t, repo, artifactRoot) @@ -126,7 +126,6 @@ func newCodexLiveRunner(t *testing.T, setupIDs ...string) codexLiveRunner { } } env := codexLiveEnv(codexHome, cleanHome, filepath.Dir(binary), openAIAPIKey, decision.mode) - env = withRecordedGateEnv(env, "SPACEDOCK_MARKETPLACE_SOURCE", marketplace) setupID := "" if len(setupIDs) > 0 { @@ -142,7 +141,6 @@ func newCodexLiveRunner(t *testing.T, setupIDs ...string) codexLiveRunner { case codexAuthOAuth, codexAuthLocal: runCodexLiveCommand(t, setupDir, "codex-login-status.txt", "", env, codexBin, "login", "status") } - runCodexLiveCommand(t, setupDir, "stable-plugin-install.txt", "", env, binary, "install", "--host", "codex") adapterPath := filepath.Join(repo, "skills", "first-officer", "references", "codex-first-officer-runtime.md") if _, err := os.Stat(adapterPath); err != nil { @@ -156,7 +154,7 @@ func newCodexLiveRunner(t *testing.T, setupIDs ...string) codexLiveRunner { t.Fatal("current-checkout source HEAD is empty") } - return codexLiveRunner{binary: binary, codexBin: codexBin, codexHome: codexHome, env: env, artifactRoot: artifactRoot} + return codexLiveRunner{binary: binary, pluginDir: livePluginDir(t), codexBin: codexBin, codexHome: codexHome, env: env, artifactRoot: artifactRoot} } func codexLiveSetupArtifactDir(artifactRoot, setupID string) string { diff --git a/internal/ensigncycle/live_test.go b/internal/ensigncycle/live_test.go index cdb101966..1e6f534d2 100644 --- a/internal/ensigncycle/live_test.go +++ b/internal/ensigncycle/live_test.go @@ -5,8 +5,6 @@ package ensigncycle import ( - "encoding/json" - "fmt" "os" "os/exec" "path/filepath" @@ -89,12 +87,11 @@ func cachedLivePluginDir(t *testing.T, repo string) string { livePluginOnce.Do(func() { // MkdirTemp (not t.TempDir) so the staged plugin outlives the first test's // cleanup and the cached path stays valid for every scenario in the run. - marketplace, err := os.MkdirTemp("", "spacedock-live-plugin-") + staged, err := os.MkdirTemp("", "spacedock-live-plugin-") if err != nil { livePluginErr = err return } - staged := filepath.Join(marketplace, "spacedock") for _, sub := range []string{".claude-plugin", ".codex-plugin", "skills", "agents"} { src := filepath.Join(repo, sub) if _, statErr := os.Stat(src); statErr != nil { @@ -105,16 +102,6 @@ func cachedLivePluginDir(t *testing.T, repo string) string { return } } - manifestDir := filepath.Join(marketplace, ".claude-plugin") - if err := os.MkdirAll(manifestDir, 0o755); err != nil { - livePluginErr = err - return - } - manifest := []byte("{\n \"name\": \"spacedock\",\n \"owner\": { \"name\": \"Spacedock live suite\" },\n \"plugins\": [\n { \"name\": \"spacedock\", \"source\": \"./spacedock\", \"description\": \"release candidate\", \"category\": \"workflow\" }\n ]\n}\n") - if err := os.WriteFile(filepath.Join(manifestDir, "marketplace.json"), manifest, 0o644); err != nil { - livePluginErr = err - return - } // git init so the FO's `git rev-parse --show-toplevel` resolves to this // workflow-free root, not an enclosing checkout that has a docs/dev. testgit.InitRepo(t, staged, "-q") @@ -126,50 +113,6 @@ func cachedLivePluginDir(t *testing.T, repo string) string { return livePluginPath } -var ( - stableLiveBinaryOnce sync.Once - stableLiveBinaryPath string - stableLiveBinaryErr error -) - -// stableLiveRelease returns the current plugin packaged as the stable channel -// plus a binary stamped with that package's release version and channel. Every -// common live journey installs this package before using the ordinary front door. -func stableLiveRelease(t *testing.T) (binary, marketplace string) { - t.Helper() - plugin := livePluginDir(t) - stableLiveBinaryOnce.Do(func() { - manifestData, err := os.ReadFile(filepath.Join(plugin, ".claude-plugin", "plugin.json")) - if err != nil { - stableLiveBinaryErr = err - return - } - var manifest struct { - Version string `json:"version"` - } - if err := json.Unmarshal(manifestData, &manifest); err != nil { - stableLiveBinaryErr = err - return - } - buildDir, err := os.MkdirTemp("", "spacedock-live-stable-") - if err != nil { - stableLiveBinaryErr = err - return - } - stableLiveBinaryPath = filepath.Join(buildDir, "spacedock") - stamp := fmt.Sprintf("-X github.com/spacedock-dev/spacedock/internal/cli.Version=%s -X github.com/spacedock-dev/spacedock/internal/cli.devBranch=main", manifest.Version) - cmd := exec.Command("go", "build", "-ldflags", stamp, "-o", stableLiveBinaryPath, "./cmd/spacedock") - cmd.Dir = repoRoot(t) - if out, err := cmd.CombinedOutput(); err != nil { - stableLiveBinaryErr = fmt.Errorf("build stable live binary: %w: %s", err, out) - } - }) - if stableLiveBinaryErr != nil { - t.Fatal(stableLiveBinaryErr) - } - return stableLiveBinaryPath, filepath.Dir(plugin) -} - // copyTree recursively copies src to dst, preserving file modes. Symlinks are // resolved to real files so the staged plugin has no path back into the real repo. func copyTree(src, dst string) error { diff --git a/internal/ensigncycle/pi_evidence_grade_impl_test.go b/internal/ensigncycle/pi_evidence_grade_impl_test.go index 3c06f55ae..201feba38 100644 --- a/internal/ensigncycle/pi_evidence_grade_impl_test.go +++ b/internal/ensigncycle/pi_evidence_grade_impl_test.go @@ -232,24 +232,3 @@ func validPiBootContractEvidence() piBootContractEvidence { Transcript: "/run/child-transcript.jsonl", } } - -func TestLivePiFrontDoorSmokeRetainsAllGraders(t *testing.T) { - source, err := os.ReadFile("pi_live_runner_test.go") - if err != nil { - t.Fatal(err) - } - start := strings.Index(string(source), "func TestLivePiFrontDoorSmoke") - if start < 0 { - t.Fatal("cannot locate TestLivePiFrontDoorSmoke") - } - end := strings.Index(string(source)[start:], "func newPiLiveSmokeFixture") - if end < 0 { - t.Fatal("cannot locate TestLivePiFrontDoorSmoke") - } - body := string(source)[start : start+end] - for _, grader := range []string{"runPiLiveCommand", "assertPiLiveSmokeResult", "assertPiEnsignBootContract"} { - if !strings.Contains(body, grader) { - t.Fatalf("TestLivePiFrontDoorSmoke lost grader %q", grader) - } - } -} diff --git a/internal/ensigncycle/pi_live_controls_test.go b/internal/ensigncycle/pi_live_controls_test.go index 5e0ab396a..157755dd9 100644 --- a/internal/ensigncycle/pi_live_controls_test.go +++ b/internal/ensigncycle/pi_live_controls_test.go @@ -34,21 +34,6 @@ Reference paths: ensign contract at %[1]s/skills/ensign/SKILL.md; Pi ensign adap repo, workflowRoot, stateRoot, entityPath, envelope.Agent, envelope.Skill, envelope.Prompt, piLiveSmokeMarker) } -func TestPiLiveSmokePromptRequiresExactStageReportHeading(t *testing.T) { - envelope := piSmokeEnvelope{Agent: "worker", Skill: "ensign", Prompt: "Read /tmp/spacedock-dispatch/x.md and treat its content as your assignment."} - prompt := piLiveSmokePrompt("/repo", "/workflow", "/workflow/.spacedock-state", "/workflow/.spacedock-state/pi-live-smoke/index.md", envelope) - want := "exact heading '## Stage Report: implementation'" - if !strings.Contains(prompt, want) { - t.Fatalf("pi live smoke prompt missing %q:\n%s", want, prompt) - } - source := readFile(t, "pi_live_runner_test.go") - for _, contract := range []string{`filepath.Join(repoRoot(t), "skills", "ensign", "SKILL.md")`, `filepath.Join(repoRoot(t), "skills", "ensign", "references", "pi-ensign-runtime.md")`} { - if !strings.Contains(source, contract) { - t.Fatalf("Pi live smoke checklist missing boot-contract action %s", contract) - } - } -} - func piLiveEnv(piHome, sessionDir, cleanHome, binaryDir, piSubagentsRoot string) []string { env := cleanEnviron( "CODEX_THREAD_ID", "CLAUDECODE", "HOME", "PI_CODING_AGENT_DIR", diff --git a/internal/ensigncycle/pi_live_runner_test.go b/internal/ensigncycle/pi_live_runner_test.go index 62c8ead64..4c993f7c4 100644 --- a/internal/ensigncycle/pi_live_runner_test.go +++ b/internal/ensigncycle/pi_live_runner_test.go @@ -135,7 +135,7 @@ func piSpacedockBinary(t *testing.T, repo string) string { func runPiSmokeDispatchBuild(t *testing.T, binary, workflowRoot, entityPath string) piSmokeEnvelope { t.Helper() checklist := []string{ - "- First read " + filepath.Join(repoRoot(t), "skills", "ensign", "SKILL.md") + " and " + filepath.Join(repoRoot(t), "skills", "ensign", "references", "pi-ensign-runtime.md") + "; then append a stage report with the exact heading '## Stage Report: implementation' containing the exact marker " + piLiveSmokeMarker + ", at least one '- DONE:' item, and a '### Summary' subsection", + "- append the smoke marker line `" + piLiveSmokeMarker + "` to the entity file", "- Commit only the entity path in the state checkout with message 'ensign: pi live smoke' (path-scoped git add/commit for pi-live-smoke/index.md)", } stdin, err := json.Marshal(map[string]any{ diff --git a/internal/ensigncycle/pi_nonself_describing_build_test.go b/internal/ensigncycle/pi_nonself_describing_build_test.go deleted file mode 100644 index ca6c06c78..000000000 --- a/internal/ensigncycle/pi_nonself_describing_build_test.go +++ /dev/null @@ -1,59 +0,0 @@ -package ensigncycle - -import ( - "path/filepath" - "testing" -) - -// writePiNonSelfDescribingSmokeWorkflow creates a split-root smoke workflow -// whose implementation stage-def names only the real work (append a marker -// line) — no "stage report" mention — so the worker's stage-report format -// source is the embedded dispatch body block, not the stage-def. -// -//spacedock:live-fixture id=pi/non-self-describing-smoke -func writePiNonSelfDescribingSmokeWorkflow(t *testing.T) (workflowRoot, stateRoot, entityPath string) { - t.Helper() - workflowRoot = t.TempDir() - stateRoot = filepath.Join(workflowRoot, ".spacedock-state") - writeFile(t, filepath.Join(workflowRoot, "README.md"), piNonSelfDescribingSmokeReadme()) - entityPath = filepath.Join(stateRoot, "pi-nonsd-smoke", "index.md") - writeFile(t, entityPath, piNonSelfDescribingSmokeEntity()) - gitInit(t, workflowRoot) - gitInit(t, stateRoot) - return workflowRoot, stateRoot, entityPath -} - -func piNonSelfDescribingSmokeReadme() string { - return "---\n" + - "entity-type: task\n" + - "id-style: slug\n" + - "state: .spacedock-state\n" + - "stages:\n" + - " defaults:\n" + - " worktree: false\n" + - " concurrency: 1\n" + - " states:\n" + - " - name: implementation\n" + - " initial: true\n" + - " - name: done\n" + - " terminal: true\n" + - "---\n" + - "# Pi Non-Self-Describing Smoke\n\n" + - "### implementation\n\n" + - "Append the live Pi smoke marker line `PI-NONSD-SMOKE-MARKER` as a standalone line to the entity file, then commit only the entity path in the state checkout.\n\n" + - "- **Outputs:** The marker line present in the entity file and a path-scoped state commit.\n\n" + - "### done\n\nTerminal state.\n" -} - -func piNonSelfDescribingSmokeEntity() string { - return "---\n" + - "id: pi-nonsd-smoke\n" + - "title: Pi Non-Self-Describing Smoke\n" + - "status: implementation\n" + - "completed:\n" + - "verdict:\n" + - "worktree:\n" + - "---\n" + - "# Pi Non-Self-Describing Smoke\n\n" + - "This entity is mutated only by the Pi subagent non-self-describing live smoke.\n" -} diff --git a/internal/ensigncycle/pi_nonself_describing_live_test.go b/internal/ensigncycle/pi_nonself_describing_live_test.go deleted file mode 100644 index fec0efb17..000000000 --- a/internal/ensigncycle/pi_nonself_describing_live_test.go +++ /dev/null @@ -1,174 +0,0 @@ -//go:build live - -package ensigncycle - -import ( - "bytes" - "encoding/json" - "fmt" - "os" - "os/exec" - "path/filepath" - "strings" - "testing" -) - -// TestLivePiNonSelfDescribingDispatch (AC-1, AC-3) is the tautology-closing -// live lane: it dispatches a Pi worker through `dispatch build --host pi` with -// a checklist equal to a real entity's acceptance criteria — no "First read -// ensign/SKILL.md", no stage-report heading, no DONE/Summary structure — and -// asserts the worker still writes a complete `## Stage Report: implementation` -// (heading + `- DONE:` + `### Summary`) with a clean state-checkout commit. -// The worker's only format source is the ensign skill the firstActionBlock -// block the build artifact now carries for host=pi. Reverting the AC-2 body -// embed makes this lane RED (the worker has no format source), while the -// self-describing TestLivePiFrontDoorSmoke stays green — proving this lane -// tests the real mode, not a fixture hint. -// -//spacedock:live-proof id=pi-non-self-describing-dispatch lane=pi-live -func TestLivePiNonSelfDescribingDispatch(t *testing.T) { - repo := repoRoot(t) - piSubagentsRoot := piSubagentsPackageRoot(t) - binary := piSpacedockBinary(t, repo) - workflowRoot, stateRoot, entityPath, artifactDir, env, model := newPiNonSelfDescribingSmokeFixture(t, "pi-nonsd-smoke", repo, piSubagentsRoot, binary) - - envelope := runPiNonSelfDescribingDispatchBuild(t, binary, workflowRoot, entityPath) - prompt := piNonSelfDescribingSmokePrompt(repo, workflowRoot, stateRoot, entityPath, envelope) - runPiLiveCommand(t, artifactDir, workflowRoot, env, binary, - "pi", - prompt, - "--plugin-dir", repo, - "--", - "--print", - "--model", model, - "--session-dir", filepath.Join(artifactDir, "sessions"), - ) - assertPiNonSelfDescribingSmokeResult(t, stateRoot, entityPath, artifactDir) -} - -func newPiNonSelfDescribingSmokeFixture(t *testing.T, name, repo, piSubagentsRoot, binary string) (workflowRoot, stateRoot, entityPath, artifactDir string, env []string, model string) { - t.Helper() - piHome := t.TempDir() - sessionDir := t.TempDir() - cleanHome := t.TempDir() - decision := seedPiLiveAuth(t, piHome, os.Getenv("HOME"), os.Getenv("CODEX_AUTH_JSON"), os.Getenv("OPENAI_API_KEY"), os.Getenv("SPACEDOCK_PI_LIVE_REQUIRED")) - writeFile(t, filepath.Join(piHome, "settings.json"), fmt.Sprintf("{\"packages\":[%q]}\n", "file:"+repo)) - writePiSubagentsProjectArtifactDir(t, piHome) - workflowRoot, stateRoot, entityPath = writePiNonSelfDescribingSmokeWorkflow(t) - artifactDir = filepath.Join(piLiveArtifactDir(t, name), "run") - if err := os.MkdirAll(filepath.Join(artifactDir, "sessions"), 0o755); err != nil { - t.Fatal(err) - } - env = piLiveEnvForAuth(piHome, sessionDir, cleanHome, filepath.Dir(binary), piSubagentsRoot, os.Getenv("OPENAI_API_KEY"), decision.mode) - model = piLiveChildModel(decision) - return workflowRoot, stateRoot, entityPath, artifactDir, env, model -} - -// runPiNonSelfDescribingDispatchBuild assembles the initial-dispatch artifact -// for the non-self-describing smoke entity with a checklist equal to a real -// entity's acceptance criteria: no ensign skill path, no stage-report heading, -// no DONE/Summary structure. The worker's only stage-report format source is -// the ensign skill the firstActionBlock directs the worker to load for -// host=pi (AC-2). -func runPiNonSelfDescribingDispatchBuild(t *testing.T, binary, workflowRoot, entityPath string) piSmokeEnvelope { - t.Helper() - // A real-entity acceptance-criteria checklist: the work to do and the - // commit discipline, with zero format or skill-path hints. - checklist := []string{ - "- append the smoke marker line `PI-NONSD-SMOKE-MARKER` to the entity file", - "- commit only the entity path in the state checkout with message 'ensign: pi live smoke' (path-scoped git add/commit for pi-nonsd-smoke/index.md)", - } - stdin, err := json.Marshal(map[string]any{ - "schema_version": 2, - "entity_path": entityPath, - "workflow_dir": workflowRoot, - "stage": "implementation", - "checklist": checklist, - "bare_mode": true, - "host": "pi", - }) - if err != nil { - t.Fatal(err) - } - cmd := exec.Command(binary, "dispatch", "build", "--workflow-dir", workflowRoot) - cmd.Dir = workflowRoot - cmd.Stdin = strings.NewReader(string(stdin)) - var stdout, stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr - if err := cmd.Run(); err != nil { - t.Fatalf("dispatch build --host pi failed: %v\nstderr:\n%s", err, stderr.String()) - } - out := stdout.Bytes() - var envelope piSmokeEnvelope - if err := json.Unmarshal(out, &envelope); err != nil { - t.Fatalf("dispatch build stdout is not the build envelope: %v\n%s\nstderr:\n%s", err, out, stderr.String()) - } - if envelope.Agent != "worker" || envelope.Skill != "ensign" { - t.Fatalf("pi build envelope = agent %q skill %q, want worker/ensign:\n%s", envelope.Agent, envelope.Skill, out) - } - if envelope.Prompt == "" || envelope.DispatchFile == "" { - t.Fatalf("pi build envelope missing prompt/dispatch_file_path:\n%s", out) - } - // Adversarial guard: confirm the dispatch body's First action block - // directs the worker to load the ensign skill (the format source), not - // that the body carries the format inline (the embed was removed). - body, err := os.ReadFile(envelope.DispatchFile) - if err != nil { - t.Fatalf("read dispatch artifact: %v", err) - } - bodyStr := string(body) - for _, want := range []string{"/skill:ensign", "skills/ensign/SKILL.md"} { - if !strings.Contains(bodyStr, want) { - t.Fatalf("non-self-describing dispatch body missing skill-load instruction %q:\n%s", want, bodyStr) - } - } - if strings.Contains(bodyStr, "### Stage Report format") { - t.Fatalf("non-self-describing dispatch body still carries the removed embed block") - } - return envelope -} - -// piNonSelfDescribingSmokePrompt is the FO prompt for the non-self-describing -// lane. It forwards the dispatch artifact's spawn fields verbatim and, after -// the worker returns, verifies the entity carries a complete -// `## Stage Report: implementation` and the state git log has the worker -// commit. Unlike piLiveSmokePrompt it does NOT tell the FO to verify the -// smoke marker — the marker is the worker's real work, not the proof; the -// stage report (sourced from the loaded ensign skill) is the proof. -func piNonSelfDescribingSmokePrompt(repo, workflowRoot, stateRoot, entityPath string, envelope piSmokeEnvelope) string { - return fmt.Sprintf(`You are the Spacedock first officer for a live Pi smoke test. - -An initial-dispatch artifact was assembled for the entity with `+"`spacedock dispatch build --host pi`"+`; forward it through pi-subagents exactly as emitted — this smoke exists to prove the build artifact's embedded stage-report format drives the worker's report even when the checklist does not name the format. - - agent: %[5]s - skill: %[6]s - task: %[7]s - -Use the pi-subagents subagent(...) tool exactly once with those fields verbatim (context must be "fresh", working directory %[2]s). Do not use or mention Claude Agent, SendMessage, TeamCreate, or TeamDelete tools. Do not paraphrase, re-order, or extend the task string. - -After subagent(...) returns, you as first officer must verify the entity file %[4]s contains a '## Stage Report: implementation' section with at least one '- DONE:' item and a '### Summary' subsection, and verify the state checkout %[3]s git log contains 'ensign: pi live smoke' over pi-nonsd-smoke/index.md. Exit successfully only after those durable checks pass; your final message names the agent and skill values you passed to subagent(...) and the child's run id. - -Reference paths: ensign contract at %[1]s/skills/ensign/SKILL.md; Pi ensign adapter at %[1]s/skills/ensign/references/pi-ensign-runtime.md (the worker's dispatch artifact already points at them).`, - repo, workflowRoot, stateRoot, entityPath, envelope.Agent, envelope.Skill, envelope.Prompt) -} - -func assertPiNonSelfDescribingSmokeResult(t *testing.T, stateRoot, entityPath, artifactDir string) { - t.Helper() - entity := readFile(t, entityPath) - // The complete stage report structure (heading + DONE + Summary) plus the - // durable git commit prove the spawned worker followed the embedded - // stage-report format block without the checklist naming it. - for _, want := range []string{"## Stage Report: implementation", "- DONE:", "### Summary"} { - if !strings.Contains(entity, want) { - t.Fatalf("entity missing %q after non-self-describing pi subagent smoke; artifacts in %s\n%s", want, artifactDir, entity) - } - } - log := git(t, stateRoot, "log", "--oneline", "--", "pi-nonsd-smoke", "index.md") - if !strings.Contains(log, "ensign: pi live smoke") { - t.Fatalf("state checkout git log missing worker commit; artifacts in %s\n%s", artifactDir, log) - } - if strings.TrimSpace(git(t, stateRoot, "status", "--short", "--", "pi-nonsd-smoke", "index.md")) != "" { - t.Fatalf("state checkout entity has uncommitted changes after worker commit; artifacts in %s\n%s", artifactDir, git(t, stateRoot, "status", "--short")) - } -} diff --git a/internal/ensigncycle/team_capability_test.go b/internal/ensigncycle/team_capability_test.go index 0c28ce06b..17e269a31 100644 --- a/internal/ensigncycle/team_capability_test.go +++ b/internal/ensigncycle/team_capability_test.go @@ -64,6 +64,8 @@ func TestCleanupKeepMovingRootRetainsOnlyFailures(t *testing.T) { func codexLiveFrontDoorArgv(pluginDir, workflowRoot, finalPath, prompt string) []string { return []string{ "codex", + "--plugin-dir", pluginDir, + "--skip-compat-check", prompt, "--", "exec", @@ -106,12 +108,12 @@ func TestCodexLiveRunnerUsesSpacedockFrontDoorBeforeHostArgs(t *testing.T) { if fence < 0 { t.Fatalf("Codex live argv has no host-argument fence: %v", args) } - if args[0] != "codex" { - t.Fatalf("Codex front door is not first: %v", args) + if args[0] != "codex" || !argvHasAdjacent(args[:fence], "--plugin-dir", "/tmp/plugin") { + t.Fatalf("Spacedock-owned Codex setup is not before host args: %v", args) } - for _, arg := range args { + for _, arg := range args[fence+1:] { if arg == "--plugin-dir" || arg == "/tmp/plugin" || arg == "--skip-compat-check" { - t.Fatalf("common live runner bypassed the installed stable package: %v", args) + t.Fatalf("Spacedock-owned argument leaked after host fence: %v", args) } } if args[fence+1] != "exec" { diff --git a/internal/gates/round_test.go b/internal/gates/round_test.go index 6b32e622b..0781058e8 100644 --- a/internal/gates/round_test.go +++ b/internal/gates/round_test.go @@ -151,80 +151,23 @@ func TestRoundAcceptsWorkflowForeignLabelsAndActors(t *testing.T) { } func TestRoundPublishesFlatAndFolderThroughSharedReviewHome(t *testing.T) { - for _, form := range []string{"folder", "flat"} { - t.Run(form, func(t *testing.T) { - var root, entity, briefing, log string - if form == "folder" { - root, entity, briefing, log, _ = advisoryRoundFixture(t) - } else { - root, entity, briefing, log, _ = flatAdvisoryRoundFixture(t) - } - before := lifecycleBytes(t, entity) - if err := RecordSemantic(entity, inputForRound(briefing, log)); err != nil { - t.Fatal(err) - } - pointer, err := readRoundPointerData(mustReadBytes(t, entity)) - if err != nil || pointer.Briefing.RoomRef != "@review/implementation/round-1" { - t.Fatalf("pointer=%#v err=%v", pointer, err) - } - if !bytes.Equal(before, lifecycleBytes(t, entity)) { - t.Fatal("round changed status or gates") - } - if _, err := os.Stat(filepath.Join(root, "review", "implementation", "round-1", "briefing.json")); err != nil { - t.Fatal(err) - } - if _, err := ValidateRoundFile(entity, "implementation/1"); err != nil { - t.Fatal(err) - } - }) + root, entity, briefing, log, _ := flatAdvisoryRoundFixture(t) + before := lifecycleBytes(t, entity) + if err := RecordSemantic(entity, inputForRound(briefing, log)); err != nil { + t.Fatal(err) } - - t.Run("declared folder policy refuses a new flat home and grandfathers an existing one", func(t *testing.T) { - root, entity, briefing, log, _ := flatAdvisoryRoundFixture(t) - state := filepath.Dir(root) - readme := "---\nentity-form: folder\nstages:\n states:\n - name: implementation\n---\n# Workflow\n" - if err := os.WriteFile(filepath.Join(state, "README.md"), []byte(readme), 0o644); err != nil { - t.Fatal(err) - } - before := treeDigest(t, state) - if err := RecordSemantic(entity, inputForRound(briefing, log)); err == nil || treeDigest(t, state) != before { - t.Fatalf("declared-folder refusal err=%v or changed bytes", err) - } - if err := os.MkdirAll(filepath.Join(root, "review", "retained"), 0o755); err != nil { - t.Fatal(err) - } - if err := RecordSemantic(entity, inputForRound(briefing, log)); err != nil { - t.Fatalf("grandfathered flat round: %v", err) - } - }) -} - -func TestRoundArtifactBoundaryIsByteCleanForFlatAndFolder(t *testing.T) { - for _, form := range []string{"folder", "flat"} { - for _, row := range []string{"outside home", "mutable entity"} { - t.Run(form+"/"+row, func(t *testing.T) { - root, entity, briefing, log := roundFixtureForForm(t, form) - target, uri := filepath.Join(filepath.Dir(root), "sibling.txt"), "../../../../sibling.txt" - if row == "mutable entity" { - target, uri = entity, "../../../index.md" - if form == "flat" { - uri = "../../../../task.md" - } - } else if err := os.WriteFile(target, []byte("sibling\n"), 0o644); err != nil { - t.Fatal(err) - } - body := bytes.Replace(mustReadBytes(t, briefing), []byte("../../../candidate.patch"), []byte(uri), 1) - body = bytes.Replace(body, []byte("sha256:8e85d4c9523a617e05b17c92390b10b2f9892152ca348433311230ac3ad98dd3"), []byte(RawDigest(mustReadBytes(t, target))), 1) - if err := os.WriteFile(briefing, body, 0o644); err != nil { - t.Fatal(err) - } - state := filepath.Dir(root) - before := treeDigest(t, state) - if err := RecordSemantic(entity, inputForRound(briefing, log)); err == nil || treeDigest(t, state) != before { - t.Fatalf("boundary refusal err=%v or changed bytes", err) - } - }) - } + pointer, err := readRoundPointerData(mustReadBytes(t, entity)) + if err != nil || pointer.Briefing.RoomRef != "@review/implementation/round-1" { + t.Fatalf("pointer=%#v err=%v", pointer, err) + } + if !bytes.Equal(before, lifecycleBytes(t, entity)) { + t.Fatal("round changed status or gates") + } + if _, err := os.Stat(filepath.Join(root, "review", "implementation", "round-1", "briefing.json")); err != nil { + t.Fatal(err) + } + if _, err := ValidateRoundFile(entity, "implementation/1"); err != nil { + t.Fatal(err) } } @@ -250,32 +193,22 @@ func TestRoundFrozenLegacyFolderPointerReplaysUnchanged(t *testing.T) { } func TestRoundReplayRefusesSiblingAndMutableEntityArtifacts(t *testing.T) { - for _, form := range []string{"folder", "flat"} { - t.Run(form, func(t *testing.T) { - root, entity, briefing, log := roundFixtureForForm(t, form) - if err := RecordSemantic(entity, inputForRound(briefing, log)); err != nil { - t.Fatal(err) - } - candidate := filepath.Join(root, "candidate.patch") - sibling := filepath.Join(filepath.Dir(root), "sibling.patch") - copyRoundFixture(t, sibling, "candidate.patch") - for _, target := range []string{sibling, entity} { - if err := os.Remove(candidate); err != nil { - t.Fatal(err) - } - if err := os.Symlink(target, candidate); err != nil { - t.Fatal(err) - } - before := treeDigest(t, filepath.Dir(root)) - if _, err := ValidateRoundFile(entity, "implementation/1"); err == nil || treeDigest(t, filepath.Dir(root)) != before { - t.Fatalf("replay target %s was accepted or changed bytes", target) - } - if err := os.Remove(candidate); err != nil { - t.Fatal(err) - } - copyRoundFixture(t, candidate, "candidate.patch") - } - }) + root, entity, briefing, log, _ := flatAdvisoryRoundFixture(t) + if err := RecordSemantic(entity, inputForRound(briefing, log)); err != nil { + t.Fatal(err) + } + candidate := filepath.Join(root, "candidate.patch") + sibling := filepath.Join(filepath.Dir(root), "sibling.patch") + copyRoundFixture(t, sibling, "candidate.patch") + if err := os.Remove(candidate); err != nil { + t.Fatal(err) + } + if err := os.Symlink(sibling, candidate); err != nil { + t.Fatal(err) + } + before := treeDigest(t, filepath.Dir(root)) + if _, err := ValidateRoundFile(entity, "implementation/1"); err == nil || treeDigest(t, filepath.Dir(root)) != before { + t.Fatalf("replay target %s was accepted or changed bytes", sibling) } } From af70297ddae6ec64444849e8e3fcf57484bc16e1 Mon Sep 17 00:00:00 2001 From: CL Kao Date: Fri, 28 Aug 2026 17:32:28 -0700 Subject: [PATCH 2/5] release: keep stable tags off the edge cask --- .claude-plugin/plugin.json | 2 +- .codex-plugin/plugin.json | 2 +- .goreleaser.yaml | 17 ++++++--------- docs/releasing.md | 5 +++-- internal/release/cask_dependencies_test.go | 25 ++++++++++++++++++++++ 5 files changed, 37 insertions(+), 14 deletions(-) diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index bcdac8fb6..d6772be46 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "spacedock", - "version": "0.28.0-pre1", + "version": "0.28.0-pre2", "description": "Turn directories of markdown files into structured workflows operated by AI agents", "author": { "name": "CL Kao" diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index bcdac8fb6..d6772be46 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "spacedock", - "version": "0.28.0-pre1", + "version": "0.28.0-pre2", "description": "Turn directories of markdown files into structured workflows operated by AI agents", "author": { "name": "CL Kao" diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 4c4f13ab0..08749270f 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -112,11 +112,10 @@ homebrew_casks: # release, pinning the per-arch url + sha256 from the release assets + # checksums.txt — the seam the homebrew-tap entity consumes. `brews:` is # hard-deprecated at goreleaser v2.16; casks auto-detect the bare `spacedock` - # binary from the archive root, so no install/test stanza is given. Two casks - # ship per release: the STABLE `spacedock` cask (from the stable archive, - # installs the `main` plugin) and the EDGE `spacedock@next` cask (from the edge - # archive, installs the `next` plugin). Each pins ONLY its own channel's archive - # via `ids: []` so the two casks never cross-pin. + # binary from the archive root, so no install/test stanza is given. The config + # defines two casks, but each tag publishes only its matching channel. A final + # tag publishes `spacedock`. A prerelease tag publishes `spacedock@next`. Each + # cask pins only its channel archive via `ids: []`. - name: spacedock # `auto` skips the cask bump when goreleaser marks the release a pre-release # (release.prerelease: auto flags a hyphenated tag like v0.23.0-pre), so a @@ -174,11 +173,9 @@ homebrew_casks: # devBranch=next binary that installs the `next` plugin. Its on-demand re-pull # cadence rides the unchanged next-publish.yml; goreleaser only emits the cask. - name: spacedock@next - # `false` publishes the edge cask on every tag, including pre-releases, so the - # edge tap tracks `-pre` cuts. This diverges from the stable cask's `auto` - # (which skips on a pre-release) — the edge channel is meant to surface - # pre-release builds, while stable never gets a pre-release. - skip_upload: false + # Skip final tags. Publish only prerelease tags, where `.Prerelease` is not + # empty. This is the inverse of the stable cask's `auto` routing. + skip_upload: '{{ eq .Prerelease "" }}' ids: - spacedock-edge # The archive ships the binary as `spacedock`, so the edge command is also diff --git a/docs/releasing.md b/docs/releasing.md index 3b4135aad..0db194a27 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -20,8 +20,9 @@ marketplace source. prerelease's `_stable` tarball — it exists only because the cask pipe cannot tolerate a skipped build; - publishes the GitHub Release with those assets; -- bumps BOTH `spacedock-dev/homebrew-tap` casks (`spacedock` stable + - `spacedock@next` edge) via `HOMEBREW_TAP_TOKEN`; +- updates one matching cask in `spacedock-dev/homebrew-tap` via + `HOMEBREW_TAP_TOKEN`: final tags update `spacedock`, and prerelease tags update + `spacedock@next`; - stamps the plugin manifests' `version` on `main` on a `.0` release only (a patch tag does not touch `main`), then advances the stable channel ref (see below). diff --git a/internal/release/cask_dependencies_test.go b/internal/release/cask_dependencies_test.go index ed9a932d0..64c29a08a 100644 --- a/internal/release/cask_dependencies_test.go +++ b/internal/release/cask_dependencies_test.go @@ -177,6 +177,7 @@ func renderCasks(t *testing.T) (map[string]string, bool) { type goreleaserCaskConfig struct { HomebrewCasks []struct { Name string `yaml:"name"` + SkipUpload string `yaml:"skip_upload"` Caveats string `yaml:"caveats"` Dependencies []struct { Cask string `yaml:"cask"` @@ -185,6 +186,30 @@ type goreleaserCaskConfig struct { } `yaml:"homebrew_casks"` } +// TestCaskReleaseChannelRouting makes sure that a final tag cannot replace the +// edge cask and that a prerelease tag cannot replace the stable cask. +func TestCaskReleaseChannelRouting(t *testing.T) { + var cfg goreleaserCaskConfig + if err := yaml.Unmarshal([]byte(readGoreleaserConfig(t)), &cfg); err != nil { + t.Fatalf("parse .goreleaser.yaml: %v", err) + } + want := map[string]string{ + "spacedock": "auto", + "spacedock@next": `{{ eq .Prerelease "" }}`, + } + for _, cask := range cfg.HomebrewCasks { + if expected, ok := want[cask.Name]; ok { + if cask.SkipUpload != expected { + t.Errorf("cask %q skip_upload = %q, want %q", cask.Name, cask.SkipUpload, expected) + } + delete(want, cask.Name) + } + } + for name := range want { + t.Errorf("missing cask %q", name) + } +} + // assertCaskConfig is the fallback proof: it parses .goreleaser.yaml's // homebrew_casks declarations and asserts the same AC-1/AC-2 properties the // render check proves on the emitted Ruby — each cask declares a `cask: From 6209e9450d6eb3f32ddc0a8fb3e15391ca7b010c Mon Sep 17 00:00:00 2001 From: CL Kao Date: Fri, 28 Aug 2026 12:55:09 -0700 Subject: [PATCH 3/5] pi: pin the installed Spacedock package to the launcher release - piPinnedSource derives the install source from the binary's release identity (linker stamp, then build-info semver tag for proxy builds, else the dev sentinel that keeps the floating source) - the spacedock pi front door owns one repair attempt for a missing, unpinned, or wrong-line package entry, rechecks, and refuses to launch on remaining mismatch; suppressed for non-git entries, dev builds, and --plugin-dir/SPACEDOCK_REPO_ROOT runs - behavior tests for the pin derivation, install call site, repair triggers/suppression, and the one-install/one-recheck/no-launch gate - registers the pi-front-door-pinned-package live journey (AC-1) --- internal/cli/pi.go | 55 ++- internal/cli/pi_frontdoor_test.go | 9 + internal/cli/pi_package_repair_test.go | 335 ++++++++++++++++++ internal/cli/pi_package_source.go | 150 ++++++++ .../ensigncycle/pi_frontdoor_pin_live_test.go | 96 +++++ 5 files changed, 644 insertions(+), 1 deletion(-) create mode 100644 internal/cli/pi_package_repair_test.go create mode 100644 internal/cli/pi_package_source.go create mode 100644 internal/ensigncycle/pi_frontdoor_pin_live_test.go diff --git a/internal/cli/pi.go b/internal/cli/pi.go index 2a453161a..99bbf9e49 100644 --- a/internal/cli/pi.go +++ b/internal/cli/pi.go @@ -206,6 +206,18 @@ func runPi(ctx context.Context, args []string, dir string, env []string, ops piR } cfg := piRuntimeConfigFromEnv(env, dir, lastString(pluginDirs)) check := checkPiRuntime(ops, cfg) + // The front door owns one Spacedock-package repair attempt + // (pin-pi-package-to-binary-release): a release-shaped binary whose entry + // is missing, unpinned, or wrong-line installs its own pinned ref — + // proactively, even when the package would "launch", because the + // first-officer binary gate aborts inside pi on a wrong-line package. + // Suppressed for dev-sentinel binaries, non-git entries, and declared dev + // overrides (--plugin-dir / SPACEDOCK_REPO_ROOT own the package surface + // for their run). The recheck below, never a second attempt, decides + // launch readiness. + if piRepairAttempt(ops, cfg, check, stderr) { + check = checkPiRuntime(ops, cfg) + } if !piRuntimeLaunchReady(check) { fmt.Fprint(stderr, "spacedock pi: Pi runtime is not ready; run `spacedock doctor --host pi` or `spacedock install --host pi`\n") printPiDoctorReport(stdout, check) @@ -365,7 +377,12 @@ func runInitWithPi(ctx context.Context, args []string, hostOps hostOps, piOps pi return runInit(ctx, args, hostOps, stdout, stderr) } if !checkOnly { - source := piSpacedockPackageSource + // Pin the default install source to the running launcher's release + // identity (pin-pi-package-to-binary-release): a release-shaped binary + // installs its own tag so Pi never floats the package across release + // lines; a dev-sentinel build keeps the bare floating source. The + // --plugin-dir dev override still wins when declared. + source := piPinnedSource(piReleaseRef()) if pluginDir != "" { source = pluginDir } @@ -612,6 +629,42 @@ func piRuntimeLaunchReady(c piCheckResult) bool { return c.piBinOK && c.extensionOK && c.subagentsSkillOK && c.subagentsIntercomBridgeOK && c.intercomPackageOK && c.intercomSkillOK && c.spacedockPackageOK } +// piRepairAttempt runs the `spacedock pi` front door's one Spacedock-package +// repair (pin-pi-package-to-binary-release). It reports whether an install +// was attempted, so the caller rechecks the runtime before the launch gate. +// Exactly one install runs per launch; the recheck — never a second attempt — +// decides launch readiness. +func piRepairAttempt(ops piRuntimeOps, cfg piRuntimeConfig, check piCheckResult, stderr io.Writer) bool { + // A declared development override owns the package surface for its run: + // the repair never fights --plugin-dir / SPACEDOCK_REPO_ROOT. + if cfg.repoRoot != "" { + return false + } + // The repair cannot help when pi itself is unavailable — and `pi install` + // needs the pi binary anyway. (The failure being outside the package gate + // does not matter here: piPackageNeedsRepair already scopes the trigger to + // the package entry, including the proactive wrong-line case where the + // package currently "launches" but the FO binary gate would abort.) + if !check.piBinOK { + return false + } + ref := piReleaseRef() + needed, reason := piPackageNeedsRepair(check.packageStatus, ref) + if !needed { + return false + } + source := piPinnedSource(ref) + fmt.Fprintf(stderr, "spacedock pi: Spacedock package %s; installing pinned source %s\n", reason, source) + out, err := ops.PiInstall(source) + if strings.TrimSpace(out) != "" { + fmt.Fprint(stderr, out) + } + if err != nil { + fmt.Fprintf(stderr, "spacedock pi: package repair install failed: %v\n", err) + } + return true // one attempt spent; the caller's recheck decides the launch +} + func piDoctorHealthy(c piCheckResult) bool { return piRuntimeLaunchReady(c) && c.authOK } diff --git a/internal/cli/pi_frontdoor_test.go b/internal/cli/pi_frontdoor_test.go index 0a51f97aa..3f7a2e97b 100644 --- a/internal/cli/pi_frontdoor_test.go +++ b/internal/cli/pi_frontdoor_test.go @@ -23,6 +23,11 @@ type fakePiRuntimeOps struct { piInstallOut string piInstallErr error packageStatus piPackageStatus + // statusAfterInstall, when set, replaces packageStatus for every + // SpacedockPackageStatus call after the first PiInstall — the model for + // "the repair ran; here is the recheck result". + statusAfterInstall *piPackageStatus + statusCalls int } func (f *fakePiRuntimeOps) LookPath(name string) (string, error) { @@ -51,6 +56,10 @@ func (f *fakePiRuntimeOps) PiInstall(source string) (string, error) { } func (f *fakePiRuntimeOps) SpacedockPackageStatus(agentDir, home string) piPackageStatus { + f.statusCalls++ + if f.statusAfterInstall != nil && len(f.piInstalls) > 0 { + return *f.statusAfterInstall + } return f.packageStatus } diff --git a/internal/cli/pi_package_repair_test.go b/internal/cli/pi_package_repair_test.go new file mode 100644 index 000000000..f2694fa43 --- /dev/null +++ b/internal/cli/pi_package_repair_test.go @@ -0,0 +1,335 @@ +package cli + +import ( + "bytes" + "context" + "errors" + "strings" + "testing" +) + +// withPiReleaseIdentity overrides the binary's release identity (linker stamp +// + build-info main version) and restores both derivation seams on cleanup. +// stamp uses the goreleaser form (no "v" prefix) or the "dev" sentinel; +// buildInfoVersion models debug.ReadBuildInfo's main module version ("v0.27.2" +// for a proxy install, "(devel)" for a checkout build). +func withPiReleaseIdentity(t *testing.T, stamp, buildInfoVersion string) { + t.Helper() + savedVersion, savedBuildInfo := Version, piBuildInfoMainVersion + Version = stamp + piBuildInfoMainVersion = func() string { return buildInfoVersion } + t.Cleanup(func() { + Version, piBuildInfoMainVersion = savedVersion, savedBuildInfo + }) +} + +// piRepairFixture creates the repo + pi-subagents package roots and their +// stat fixtures, so a runPi launch reaches the launch seam when the package +// gate passes. +func piRepairFixture(t *testing.T) (repo, pkg string) { + t.Helper() + repo, pkg = t.TempDir(), t.TempDir() + writePiSkillFixtures(t, repo) + writePiSubagentsFixtures(t, pkg) + return repo, pkg +} + +// piRepairOps builds the healthy-otherwise fake ops with a canned package +// status; after, when non-nil, replaces the status after the first PiInstall +// (the model for "the repair ran; here is the recheck result"). +func piRepairOps(repo, pkg string, status piPackageStatus, after *piPackageStatus) *fakePiRuntimeOps { + return &fakePiRuntimeOps{ + lookPath: piHealthyPathFixtures(), + statOK: statOKForPiResources(repo, pkg), + packageStatus: status, + statusAfterInstall: after, + } +} + +// runPiWith runs the front door with the standard temp cwd/env and the given +// extra args; returns (exit, stdout, stderr). +func runPiWith(t *testing.T, ops *fakePiRuntimeOps, pkg string, extraArgs []string) (int, string, string) { + t.Helper() + var stdout, stderr bytes.Buffer + args := append([]string{"review this"}, extraArgs...) + code := runPi(context.Background(), args, t.TempDir(), piTestEnv(pkg, t.TempDir()), ops, &stdout, &stderr) + return code, stdout.String(), stderr.String() +} + +func TestPiReleaseRefDerivation(t *testing.T) { + const pseudo1 = "v0.28.0-pre0.0.20260828120000-abcdef123456" // @next over a pre tag + const pseudo2 = "v0.27.1-0.20260828120000-abcdef123456" // no base tag + for _, tc := range []struct { + name, stamp, buildInfo, want string + }{ + {"linker stamp wins", "0.27.2", "v0.26.0", "v0.27.2"}, + {"v-prefixed stamp normalizes", "v0.27.2", "", "v0.27.2"}, + {"prerelease stamp", "0.28.0-pre1", "(devel)", "v0.28.0-pre1"}, + {"proxy build pins its tag", "dev", "v0.27.2", "v0.27.2"}, + {"checkout build is the dev sentinel", "dev", "(devel)", ""}, + {"empty build info is the dev sentinel", "dev", "", ""}, + {"next pseudo-version floats", "dev", pseudo1, ""}, + {"plain pseudo-version floats", "dev", pseudo2, ""}, + {"non-semver build info floats", "dev", "not-a-version", ""}, + {"empty stamp uses build info", "", "v0.27.2", "v0.27.2"}, + } { + if got := piReleaseRefFrom(tc.stamp, tc.buildInfo); got != tc.want { + t.Fatalf("%s: piReleaseRefFrom(%q,%q)=%q want %q", tc.name, tc.stamp, tc.buildInfo, got, tc.want) + } + } +} + +func TestPiPinnedSourceAndRefParsing(t *testing.T) { + const repo = "git:github.com/spacedock-dev/spacedock" + for _, tc := range []struct{ ref, want string }{ + {"v0.27.2", repo + "@v0.27.2"}, + {"v0.28.0-pre0", repo + "@v0.28.0-pre0"}, + {"", repo}, // dev sentinel keeps the bare floating source + } { + if got := piPinnedSource(tc.ref); got != tc.want { + t.Fatalf("piPinnedSource(%q)=%q want %q", tc.ref, got, tc.want) + } + } + for _, tc := range []struct { + source, ref string + isGit bool + }{ + {"git:github.com/spacedock-dev/spacedock", "", true}, + {"git:github.com/spacedock-dev/spacedock@v0.27.2", "v0.27.2", true}, + {"file:/tmp/repo", "", false}, + {"npm:spacedock", "", false}, + {"/usr/local/spacedock", "", false}, + {"", "", false}, + } { + if ref, ok := piGitSourceRef(tc.source); ref != tc.ref || ok != tc.isGit { + t.Fatalf("piGitSourceRef(%q)=(%q,%v) want (%q,%v)", tc.source, ref, ok, tc.ref, tc.isGit) + } + } +} + +func TestPiPackageNeedsRepairTable(t *testing.T) { + missing := piPackageStatus{} + unpinned := piPackageStatus{registered: true, source: "git:github.com/spacedock-dev/spacedock"} + wrongLine := piPackageStatus{registered: true, source: "git:github.com/spacedock-dev/spacedock@v0.28.0-pre1"} + ownRef := piPackageStatus{registered: true, source: "git:github.com/spacedock-dev/spacedock@v0.27.2"} + userEntry := piPackageStatus{registered: true, source: "file:/x"} + for _, tc := range []struct { + name string + st piPackageStatus + relRef string + want bool + wantReason string + }{ + {"missing entry, release-shaped", missing, "v0.27.2", true, "missing"}, + {"unpinned git entry", unpinned, "v0.27.2", true, "unpinned"}, + {"wrong release line", wrongLine, "v0.27.2", true, "wrong release line"}, + {"pinned to own ref", ownRef, "v0.27.2", false, ""}, + {"non-git entry is user-managed", userEntry, "v0.27.2", false, ""}, + {"dev sentinel never repairs", missing, "", false, ""}, + } { + got, reason := piPackageNeedsRepair(tc.st, tc.relRef) + if got != tc.want || (got && reason != tc.wantReason) { + t.Fatalf("%s: repair=(%v,%q) want (%v,%q)", tc.name, got, reason, tc.want, tc.wantReason) + } + } +} + +func TestPiInstallUsesReleasePinnedSourcePerIdentity(t *testing.T) { + pkg := t.TempDir() + t.Run("linker-stamped binary installs its own tag", func(t *testing.T) { + withPiReleaseIdentity(t, "0.27.2", "(devel)") + piOps := &fakePiRuntimeOps{lookPath: piHealthyPathFixtures()} + var stdout, stderr bytes.Buffer + if code := runInitWithPi(context.Background(), []string{"--host", "pi"}, &fakeHost{}, piOps, piTestEnv(pkg, t.TempDir()), &stdout, &stderr); code != 0 { + t.Fatalf("exit=%d stderr=%q", code, stderr.String()) + } + want := "git:github.com/spacedock-dev/spacedock@v0.27.2" + if len(piOps.piInstalls) != 1 || piOps.piInstalls[0] != want { + t.Fatalf("stamped binary must install %q, got %v", want, piOps.piInstalls) + } + }) + + t.Run("proxy-tagged build installs its tag", func(t *testing.T) { + withPiReleaseIdentity(t, "dev", "v0.27.2") + piOps := &fakePiRuntimeOps{lookPath: piHealthyPathFixtures()} + var stdout, stderr bytes.Buffer + if code := runInitWithPi(context.Background(), []string{"--host", "pi"}, &fakeHost{}, piOps, piTestEnv(pkg, t.TempDir()), &stdout, &stderr); code != 0 { + t.Fatalf("exit=%d stderr=%q", code, stderr.String()) + } + want := "git:github.com/spacedock-dev/spacedock@v0.27.2" + if len(piOps.piInstalls) != 1 || piOps.piInstalls[0] != want { + t.Fatalf("proxy build must pin its tag: want %q, got %v", want, piOps.piInstalls) + } + }) + + t.Run("dev sentinel keeps the floating source", func(t *testing.T) { + withPiReleaseIdentity(t, "dev", "(devel)") + piOps := &fakePiRuntimeOps{lookPath: piHealthyPathFixtures()} + var stdout, stderr bytes.Buffer + if code := runInitWithPi(context.Background(), []string{"--host", "pi"}, &fakeHost{}, piOps, piTestEnv(pkg, t.TempDir()), &stdout, &stderr); code != 0 { + t.Fatalf("exit=%d stderr=%q", code, stderr.String()) + } + if len(piOps.piInstalls) != 1 || piOps.piInstalls[0] != piSpacedockPackageSource { + t.Fatalf("dev build must keep the bare floating source, got %v", piOps.piInstalls) + } + }) + + t.Run("plugin-dir override wins and never uses the release source", func(t *testing.T) { + withPiReleaseIdentity(t, "0.27.2", "(devel)") + piOps := &fakePiRuntimeOps{lookPath: piHealthyPathFixtures()} + var stdout, stderr bytes.Buffer + if code := runInitWithPi(context.Background(), []string{"--host", "pi", "--plugin-dir", "/checkout"}, &fakeHost{}, piOps, piTestEnv(pkg, t.TempDir()), &stdout, &stderr); code != 0 { + t.Fatalf("exit=%d stderr=%q", code, stderr.String()) + } + if len(piOps.piInstalls) != 1 || piOps.piInstalls[0] != "/checkout" { + t.Fatalf("plugin dir must be the install source, got %v", piOps.piInstalls) + } + }) +} + +func TestPiFrontDoorRepairsUnpinnedPackageAndLaunches(t *testing.T) { + repo, pkg := piRepairFixture(t) + // The released v0.27.2 incident shape: registered but UNPINNED. + repaired := healthyPiPackageStatus() + ops := piRepairOps(repo, pkg, piPackageStatus{registered: true, ensignDiscoverable: false, source: "git:github.com/spacedock-dev/spacedock"}, &repaired) + withPiReleaseIdentity(t, "0.27.2", "(devel)") + code, _, stderr := runPiWith(t, ops, pkg, nil) + + if code != 0 { + t.Fatalf("exit=%d stderr=%q", code, stderr) + } + if len(ops.launched) == 0 { + t.Fatalf("expected launch after repair; stderr=%q", stderr) + } + want := "git:github.com/spacedock-dev/spacedock@v0.27.2" + if len(ops.piInstalls) != 1 || ops.piInstalls[0] != want { + t.Fatalf("expected exactly one install of %q, got %v", want, ops.piInstalls) + } + // Initial check + one post-repair recheck; never a second install. + if ops.statusCalls != 2 { + t.Fatalf("expected 2 status reads (initial + recheck), got %d", ops.statusCalls) + } +} + +func TestPiFrontDoorRepairsWrongReleaseLine(t *testing.T) { + repo, pkg := piRepairFixture(t) + // The incident: @v0.28.0-pre1 installed under a v0.27.2-identity binary. + wrongLine := healthyPiPackageStatus() + wrongLine.source = "git:github.com/spacedock-dev/spacedock@v0.28.0-pre1" + repaired := healthyPiPackageStatus() + repaired.source = "git:github.com/spacedock-dev/spacedock@v0.27.2" + ops := piRepairOps(repo, pkg, wrongLine, &repaired) + withPiReleaseIdentity(t, "0.27.2", "(devel)") + code, _, stderr := runPiWith(t, ops, pkg, nil) + + if code != 0 { + t.Fatalf("exit=%d stderr=%q", code, stderr) + } + if want := "git:github.com/spacedock-dev/spacedock@v0.27.2"; len(ops.piInstalls) != 1 || ops.piInstalls[0] != want { + t.Fatalf("wrong-line package must repair to the binary's own ref %q, got %v", want, ops.piInstalls) + } + if len(ops.launched) == 0 { + t.Fatalf("expected launch after wrong-line repair") + } +} + +func TestPiFrontDoorRefusesWhenRepairInstallFails(t *testing.T) { + repo, pkg := piRepairFixture(t) + ops := piRepairOps(repo, pkg, piPackageStatus{}, nil) + ops.piInstallErr = errors.New("clone failed") + withPiReleaseIdentity(t, "0.27.2", "(devel)") + code, _, stderr := runPiWith(t, ops, pkg, nil) + + if code != 1 { + t.Fatalf("exit=%d want 1", code) + } + // AC-4: one install attempt, one recheck, no launch, actionable error. + if len(ops.piInstalls) != 1 || len(ops.launched) != 0 { + t.Fatalf("want 1 install and no launch: installs=%v launched=%v", ops.piInstalls, ops.launched) + } + if ops.statusCalls != 2 { + t.Fatalf("expected exactly one recheck after the repair, got %d status reads", ops.statusCalls) + } + if !strings.Contains(stderr, "package repair install failed") || !strings.Contains(stderr, "spacedock doctor --host pi") { + t.Fatalf("actionable error missing; stderr=%q", stderr) + } +} + +func TestPiFrontDoorRefusesAfterIneffectiveRepair(t *testing.T) { + repo, pkg := piRepairFixture(t) + stillUnpinned := piPackageStatus{registered: true, ensignDiscoverable: false, source: "git:github.com/spacedock-dev/spacedock"} + ops := piRepairOps(repo, pkg, piPackageStatus{registered: true, source: "git:github.com/spacedock-dev/spacedock"}, &stillUnpinned) + withPiReleaseIdentity(t, "0.27.2", "(devel)") + code, _, _ := runPiWith(t, ops, pkg, nil) + + if code != 1 { + t.Fatalf("exit=%d want 1", code) + } + // AC-4: no second install attempt, no launch after a remaining mismatch. + if len(ops.piInstalls) != 1 || len(ops.launched) != 0 { + t.Fatalf("one install, no launch expected: installs=%v launched=%v", ops.piInstalls, ops.launched) + } +} + +func TestPiFrontDoorSuppressedRepairs(t *testing.T) { + t.Run("non-git healthy entry never repairs", func(t *testing.T) { + repo, pkg := piRepairFixture(t) + userEntry := piPackageStatus{registered: true, ensignDiscoverable: true, source: "file:/x", packageRoot: "/x"} + ops := piRepairOps(repo, pkg, userEntry, nil) + withPiReleaseIdentity(t, "0.27.2", "(devel)") + code, _, stderr := runPiWith(t, ops, pkg, nil) + if code != 0 { + t.Fatalf("exit=%d stderr=%q", code, stderr) + } + if len(ops.piInstalls) != 0 { + t.Fatalf("user-managed entry must not be repaired: %v", ops.piInstalls) + } + }) + + t.Run("dev-sentinel binary never repairs", func(t *testing.T) { + repo, pkg := piRepairFixture(t) + ops := piRepairOps(repo, pkg, piPackageStatus{}, nil) + withPiReleaseIdentity(t, "dev", "(devel)") + code, _, stderr := runPiWith(t, ops, pkg, nil) + // A dev build has no pin target: the pre-existing refusal stands. + if code != 1 { + t.Fatalf("exit=%d want 1; stderr=%q", code, stderr) + } + if len(ops.piInstalls) != 0 { + t.Fatalf("dev build must not repair-install: %v", ops.piInstalls) + } + }) + + t.Run("plugin-dir override suppresses the repair", func(t *testing.T) { + repo, pkg := piRepairFixture(t) + ops := piRepairOps(repo, pkg, piPackageStatus{}, nil) + withPiReleaseIdentity(t, "0.27.2", "(devel)") + var stdout, stderr bytes.Buffer + code := runPi(context.Background(), []string{"review this", "--plugin-dir", repo}, t.TempDir(), piTestEnv(pkg, t.TempDir()), ops, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit=%d stderr=%q", code, stderr.String()) + } + if len(ops.piInstalls) != 0 { + t.Fatalf("dev override suppresses the repair: %v", ops.piInstalls) + } + }) +} + +func TestPiFrontDoorHealthyPinnedPackageDoesNotRepair(t *testing.T) { + repo, pkg := piRepairFixture(t) + pinned := healthyPiPackageStatus() + pinned.source = "git:github.com/spacedock-dev/spacedock@v0.27.2" + ops := piRepairOps(repo, pkg, pinned, nil) + withPiReleaseIdentity(t, "0.27.2", "(devel)") + code, _, stderr := runPiWith(t, ops, pkg, nil) + + if code != 0 { + t.Fatalf("exit=%d stderr=%q", code, stderr) + } + if len(ops.piInstalls) != 0 { + t.Fatalf("healthy pinned package must not repair: %v", ops.piInstalls) + } + if len(ops.launched) == 0 { + t.Fatalf("expected launch") + } +} diff --git a/internal/cli/pi_package_source.go b/internal/cli/pi_package_source.go new file mode 100644 index 000000000..2e4416da4 --- /dev/null +++ b/internal/cli/pi_package_source.go @@ -0,0 +1,150 @@ +// ABOUTME: Derives the Spacedock Pi package install source from the running +// launcher's release identity — release-shaped binaries pin, dev builds float. +package cli + +import ( + "regexp" + "runtime/debug" + "strings" +) + +// piReleaseRef returns the git ref a release-shaped binary pins the Spacedock +// Pi package source to, resolved in order (ideation, pin-pi-package-to-binary- +// release): +// +// 1. the linker-stamped internal/cli.Version — release artifacts set it +// (`-X ...Version={{ .Version }}`, stable and edge channels alike); +// 2. when Version is the "dev" sentinel, the Go build-info main module +// version when it is a semver tag — this covers `go install …@vX.Y.Z` +// proxy builds, which carry no ldflags but ARE release-shaped (the module +// proxy embeds the tagged manifest, which is why displayVersion reports +// them as X.Y.Z+dev); +// 3. otherwise "" — the dev sentinel: a plain `go build` / `go install +// ./cmd/spacedock` checkout build has no release identity, keeps the +// unpinned floating source, and performs no repair (no pin target). +// +// A release-shaped binary (stamped or proxy-tagged) never floats; the returned +// ref is the git tag the package source pins to. +func piReleaseRef() string { + return piReleaseRefFrom(Version, piBuildInfoMainVersion()) +} + +// piBuildInfoMainVersion is the seam for the Go build-info main module version; +// a var so tests can stub the proxy-build identity. +var piBuildInfoMainVersion = func() string { + bi, ok := debug.ReadBuildInfo() + if !ok { + return "" + } + return strings.TrimSpace(bi.Main.Version) +} + +// piReleaseRefFrom is the pure derivation over (linker stamp, build-info main +// version). A value counts as a release ref when it parses as a semver version +// (optionally "v"-prefixed, optional -preN prerelease) and is not a Go module +// pseudo-version (which go install emits for branch/HEAD resolutions like +// @next — those are dev-shaped and must float). +func piReleaseRefFrom(linkerStamp, buildInfoVersion string) string { + if ref := piSemverTag(linkerStamp); ref != "" { + return ref + } + // A proxy build self-reports Version == "dev" but embeds the tagged + // manifest; the build info carries the resolved module version. A checkout + // build reports "" or "(devel)" here, which the semver gate rejects. + if bi := buildInfoVersion; bi != "" && bi != "(devel)" && bi != "devel" { + return piSemverTag(bi) + } + return "" +} + +func piSemverTag(v string) string { + v = strings.TrimSpace(v) + if piPseudoVersion.MatchString(v) { + return "" // Go module pseudo-version (branch/HEAD resolution): dev-shaped + } + v = strings.TrimPrefix(v, "v") + if v == "" { + return "" + } + base := v + if i := strings.IndexAny(v, "-+"); i >= 0 { + base = v[:i] + } + parts := strings.Split(base, ".") + if len(parts) != 3 { + return "" + } + for _, p := range parts { + if p == "" || len(p) > 9 { + return "" + } + for _, r := range p { + if r < '0' || r > '9' { + return "" + } + } + } + // Go module pseudo-versions resolve no tag; the dev sentinel applies. + // (Matched above before normalization.) + return "v" + v +} + +// piPseudoVersion matches Go module pseudo-versions — the resolution go +// install emits for branch/HEAD specs like @next (vX.Y.Z-0.-, and +// the base-tag form vX.Y.Z-pre.0.-). Both end in a 14-digit +// timestamp followed by the commit hash; they resolve no tag, so they carry +// no release identity: the dev sentinel applies. +var piPseudoVersion = regexp.MustCompile(`\d{14}-[0-9a-f]{7,12}$`) + +// piPinnedSource returns the install source for the Spacedock Pi package given +// the binary's release ref: a ref-pinned source for a release-shaped binary +// (never floats across tags), or the bare floating source for the dev sentinel. +func piPinnedSource(releaseRef string) string { + if releaseRef == "" { + return piSpacedockPackageSource + } + return piSpacedockPackageSource + "@" + releaseRef +} + +// piGitSourceRef parses a Pi settings `packages` entry as a git source. ok is +// true only for `git:` sources; ref is the entry's @ref ("" when the git +// source is unpinned). Non-git entries (file:, npm:, local paths) return +// ok=false — they are user-managed and the repair never rewrites them. +func piGitSourceRef(source string) (ref string, ok bool) { + const prefix = "git:" + if !strings.HasPrefix(source, "git:") { + return "", false + } + spec := strings.TrimPrefix(source, "git:") + if i := strings.LastIndex(spec, "@"); i > 0 { + return spec[i+1:], true + } + return "", true +} + +// piPackageNeedsRepair reports whether the front door's one repair attempt +// must run for the discovered package status under a binary whose release ref +// is releaseRef. The repair triggers only for a release-shaped binary whose +// entry is missing, unpinned, or pinned to another ref — the ref delta IS the +// version mismatch. A non-git entry is user-managed (never rewritten), and a +// dev-sentinel binary never repairs (no pin target; a floating reinstall would +// clobber a pinned entry with an unpinned one). +func piPackageNeedsRepair(st piPackageStatus, releaseRef string) (bool, string) { + if releaseRef == "" { + return false, "" // dev sentinel: no pin target, never repairs + } + if !st.registered { + return true, "missing" + } + entryRef, isGit := piGitSourceRef(st.source) + if !isGit { + return false, "" // user-managed entry: no clobber in either direction + } + if entryRef == "" { + return true, "unpinned" + } + if entryRef != releaseRef { + return true, "wrong release line" + } + return false, "" +} diff --git a/internal/ensigncycle/pi_frontdoor_pin_live_test.go b/internal/ensigncycle/pi_frontdoor_pin_live_test.go new file mode 100644 index 000000000..dda651fbb --- /dev/null +++ b/internal/ensigncycle/pi_frontdoor_pin_live_test.go @@ -0,0 +1,96 @@ +//go:build live + +package ensigncycle + +import ( + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// piStampedSpacedockBinary builds the checkout binary as a release-shaped +// artifact: the goreleaser stamp form of the checkout manifest's own version +// (0.28.0-pre0, an existing tag), so the pinned install source resolves to a +// real remote tag in the live run. +func piStampedSpacedockBinary(t *testing.T, repo string) string { + t.Helper() + out := filepath.Join(t.TempDir(), "spacedock") + cmd := exec.Command("go", "build", "-o", out, + "-ldflags", "-X github.com/spacedock-dev/spacedock/internal/cli.Version=0.28.0-pre0", + "./cmd/spacedock") + cmd.Dir = repo + if b, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("build stamped spacedock for the pinned-package live journey: %v\n%s", err, b) + } + return out +} + +//spacedock:live-proof id=pi-front-door-pinned-package lane=pi-live +func TestLivePiFrontDoorInstallsPinnedPackage(t *testing.T) { + repo := repoRoot(t) + piSubagentsRoot := piSubagentsPackageRoot(t) + binary := piStampedSpacedockBinary(t, repo) + + // The incident fixture: a fresh pi-home whose settings.json registers NO + // Spacedock package (the post-skew state the v0.27.2 abort left behind). + piHome := t.TempDir() + sessionDir := t.TempDir() + cleanHome := t.TempDir() + decision := seedPiLiveAuth(t, piHome, os.Getenv("HOME"), os.Getenv("CODEX_AUTH_JSON"), os.Getenv("OPENAI_API_KEY"), os.Getenv("SPACEDOCK_PI_LIVE_REQUIRED")) + artifactDir := filepath.Join(piLiveArtifactDir(t, "pi-frontdoor-pin"), "run") + if err := os.MkdirAll(filepath.Join(artifactDir, "sessions"), 0o755); err != nil { + t.Fatal(err) + } + env := piLiveEnvForAuth(piHome, sessionDir, cleanHome, filepath.Dir(binary), piSubagentsRoot, os.Getenv("OPENAI_API_KEY"), decision.mode) + model := piLiveChildModel(decision) + + // The ordinary installed front door: no --plugin-dir, no SPACEDOCK_REPO_ROOT. + runPiLiveCommand(t, artifactDir, repo, env, binary, + "pi", + "Reply with the single word READY.", + "--", + "--print", + "--model", model, + "--session-dir", filepath.Join(artifactDir, "sessions"), + ) + + // AC-1 durable proof: the front door's one repair registered the binary's + // own pinned ref. + entry := piSpacedockSettingsEntry(t, piHome) + want := "git:github.com/spacedock-dev/spacedock@v0.28.0-pre0" + if !strings.Contains(entry, want) { + t.Fatalf("pinned package entry %q not registered after the repair; got entry %q", want, entry) + } +} + +// piSpacedockSettingsEntry returns the spacedock package entry (string or +// {"source": ...} form) from the pi agent dir's settings.json. +func piSpacedockSettingsEntry(t *testing.T, piHome string) string { + t.Helper() + data, err := os.ReadFile(filepath.Join(piHome, "settings.json")) + if err != nil { + t.Fatalf("read pi settings: %v", err) + } + var settings struct { + Packages []json.RawMessage `json:"packages"` + } + if err := json.Unmarshal(data, &settings); err != nil { + t.Fatalf("parse pi settings: %v", err) + } + for _, raw := range settings.Packages { + var s string + if json.Unmarshal(raw, &s) == nil && strings.Contains(s, "spacedock") { + return s + } + var obj struct { + Source string `json:"source"` + } + if json.Unmarshal(raw, &obj) == nil && strings.Contains(obj.Source, "spacedock") { + return obj.Source + } + } + return "" +} From 989e193dedc733395f88619486f9899078b60de4 Mon Sep 17 00:00:00 2001 From: CL Kao Date: Fri, 28 Aug 2026 14:58:38 -0700 Subject: [PATCH 4/5] =?UTF-8?q?pi:=20shrink=20to=20re-scoped=20surface=20?= =?UTF-8?q?=E2=80=94=20pure-function=20tables,=20hand-executed=20proof=20a?= =?UTF-8?q?t=20validation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/cli/pi_frontdoor_test.go | 9 - internal/cli/pi_package_repair_test.go | 335 ------------------ internal/cli/pi_package_source_test.go | 94 +++++ .../ensigncycle/pi_frontdoor_pin_live_test.go | 96 ----- 4 files changed, 94 insertions(+), 440 deletions(-) delete mode 100644 internal/cli/pi_package_repair_test.go create mode 100644 internal/cli/pi_package_source_test.go delete mode 100644 internal/ensigncycle/pi_frontdoor_pin_live_test.go diff --git a/internal/cli/pi_frontdoor_test.go b/internal/cli/pi_frontdoor_test.go index 3f7a2e97b..0a51f97aa 100644 --- a/internal/cli/pi_frontdoor_test.go +++ b/internal/cli/pi_frontdoor_test.go @@ -23,11 +23,6 @@ type fakePiRuntimeOps struct { piInstallOut string piInstallErr error packageStatus piPackageStatus - // statusAfterInstall, when set, replaces packageStatus for every - // SpacedockPackageStatus call after the first PiInstall — the model for - // "the repair ran; here is the recheck result". - statusAfterInstall *piPackageStatus - statusCalls int } func (f *fakePiRuntimeOps) LookPath(name string) (string, error) { @@ -56,10 +51,6 @@ func (f *fakePiRuntimeOps) PiInstall(source string) (string, error) { } func (f *fakePiRuntimeOps) SpacedockPackageStatus(agentDir, home string) piPackageStatus { - f.statusCalls++ - if f.statusAfterInstall != nil && len(f.piInstalls) > 0 { - return *f.statusAfterInstall - } return f.packageStatus } diff --git a/internal/cli/pi_package_repair_test.go b/internal/cli/pi_package_repair_test.go deleted file mode 100644 index f2694fa43..000000000 --- a/internal/cli/pi_package_repair_test.go +++ /dev/null @@ -1,335 +0,0 @@ -package cli - -import ( - "bytes" - "context" - "errors" - "strings" - "testing" -) - -// withPiReleaseIdentity overrides the binary's release identity (linker stamp -// + build-info main version) and restores both derivation seams on cleanup. -// stamp uses the goreleaser form (no "v" prefix) or the "dev" sentinel; -// buildInfoVersion models debug.ReadBuildInfo's main module version ("v0.27.2" -// for a proxy install, "(devel)" for a checkout build). -func withPiReleaseIdentity(t *testing.T, stamp, buildInfoVersion string) { - t.Helper() - savedVersion, savedBuildInfo := Version, piBuildInfoMainVersion - Version = stamp - piBuildInfoMainVersion = func() string { return buildInfoVersion } - t.Cleanup(func() { - Version, piBuildInfoMainVersion = savedVersion, savedBuildInfo - }) -} - -// piRepairFixture creates the repo + pi-subagents package roots and their -// stat fixtures, so a runPi launch reaches the launch seam when the package -// gate passes. -func piRepairFixture(t *testing.T) (repo, pkg string) { - t.Helper() - repo, pkg = t.TempDir(), t.TempDir() - writePiSkillFixtures(t, repo) - writePiSubagentsFixtures(t, pkg) - return repo, pkg -} - -// piRepairOps builds the healthy-otherwise fake ops with a canned package -// status; after, when non-nil, replaces the status after the first PiInstall -// (the model for "the repair ran; here is the recheck result"). -func piRepairOps(repo, pkg string, status piPackageStatus, after *piPackageStatus) *fakePiRuntimeOps { - return &fakePiRuntimeOps{ - lookPath: piHealthyPathFixtures(), - statOK: statOKForPiResources(repo, pkg), - packageStatus: status, - statusAfterInstall: after, - } -} - -// runPiWith runs the front door with the standard temp cwd/env and the given -// extra args; returns (exit, stdout, stderr). -func runPiWith(t *testing.T, ops *fakePiRuntimeOps, pkg string, extraArgs []string) (int, string, string) { - t.Helper() - var stdout, stderr bytes.Buffer - args := append([]string{"review this"}, extraArgs...) - code := runPi(context.Background(), args, t.TempDir(), piTestEnv(pkg, t.TempDir()), ops, &stdout, &stderr) - return code, stdout.String(), stderr.String() -} - -func TestPiReleaseRefDerivation(t *testing.T) { - const pseudo1 = "v0.28.0-pre0.0.20260828120000-abcdef123456" // @next over a pre tag - const pseudo2 = "v0.27.1-0.20260828120000-abcdef123456" // no base tag - for _, tc := range []struct { - name, stamp, buildInfo, want string - }{ - {"linker stamp wins", "0.27.2", "v0.26.0", "v0.27.2"}, - {"v-prefixed stamp normalizes", "v0.27.2", "", "v0.27.2"}, - {"prerelease stamp", "0.28.0-pre1", "(devel)", "v0.28.0-pre1"}, - {"proxy build pins its tag", "dev", "v0.27.2", "v0.27.2"}, - {"checkout build is the dev sentinel", "dev", "(devel)", ""}, - {"empty build info is the dev sentinel", "dev", "", ""}, - {"next pseudo-version floats", "dev", pseudo1, ""}, - {"plain pseudo-version floats", "dev", pseudo2, ""}, - {"non-semver build info floats", "dev", "not-a-version", ""}, - {"empty stamp uses build info", "", "v0.27.2", "v0.27.2"}, - } { - if got := piReleaseRefFrom(tc.stamp, tc.buildInfo); got != tc.want { - t.Fatalf("%s: piReleaseRefFrom(%q,%q)=%q want %q", tc.name, tc.stamp, tc.buildInfo, got, tc.want) - } - } -} - -func TestPiPinnedSourceAndRefParsing(t *testing.T) { - const repo = "git:github.com/spacedock-dev/spacedock" - for _, tc := range []struct{ ref, want string }{ - {"v0.27.2", repo + "@v0.27.2"}, - {"v0.28.0-pre0", repo + "@v0.28.0-pre0"}, - {"", repo}, // dev sentinel keeps the bare floating source - } { - if got := piPinnedSource(tc.ref); got != tc.want { - t.Fatalf("piPinnedSource(%q)=%q want %q", tc.ref, got, tc.want) - } - } - for _, tc := range []struct { - source, ref string - isGit bool - }{ - {"git:github.com/spacedock-dev/spacedock", "", true}, - {"git:github.com/spacedock-dev/spacedock@v0.27.2", "v0.27.2", true}, - {"file:/tmp/repo", "", false}, - {"npm:spacedock", "", false}, - {"/usr/local/spacedock", "", false}, - {"", "", false}, - } { - if ref, ok := piGitSourceRef(tc.source); ref != tc.ref || ok != tc.isGit { - t.Fatalf("piGitSourceRef(%q)=(%q,%v) want (%q,%v)", tc.source, ref, ok, tc.ref, tc.isGit) - } - } -} - -func TestPiPackageNeedsRepairTable(t *testing.T) { - missing := piPackageStatus{} - unpinned := piPackageStatus{registered: true, source: "git:github.com/spacedock-dev/spacedock"} - wrongLine := piPackageStatus{registered: true, source: "git:github.com/spacedock-dev/spacedock@v0.28.0-pre1"} - ownRef := piPackageStatus{registered: true, source: "git:github.com/spacedock-dev/spacedock@v0.27.2"} - userEntry := piPackageStatus{registered: true, source: "file:/x"} - for _, tc := range []struct { - name string - st piPackageStatus - relRef string - want bool - wantReason string - }{ - {"missing entry, release-shaped", missing, "v0.27.2", true, "missing"}, - {"unpinned git entry", unpinned, "v0.27.2", true, "unpinned"}, - {"wrong release line", wrongLine, "v0.27.2", true, "wrong release line"}, - {"pinned to own ref", ownRef, "v0.27.2", false, ""}, - {"non-git entry is user-managed", userEntry, "v0.27.2", false, ""}, - {"dev sentinel never repairs", missing, "", false, ""}, - } { - got, reason := piPackageNeedsRepair(tc.st, tc.relRef) - if got != tc.want || (got && reason != tc.wantReason) { - t.Fatalf("%s: repair=(%v,%q) want (%v,%q)", tc.name, got, reason, tc.want, tc.wantReason) - } - } -} - -func TestPiInstallUsesReleasePinnedSourcePerIdentity(t *testing.T) { - pkg := t.TempDir() - t.Run("linker-stamped binary installs its own tag", func(t *testing.T) { - withPiReleaseIdentity(t, "0.27.2", "(devel)") - piOps := &fakePiRuntimeOps{lookPath: piHealthyPathFixtures()} - var stdout, stderr bytes.Buffer - if code := runInitWithPi(context.Background(), []string{"--host", "pi"}, &fakeHost{}, piOps, piTestEnv(pkg, t.TempDir()), &stdout, &stderr); code != 0 { - t.Fatalf("exit=%d stderr=%q", code, stderr.String()) - } - want := "git:github.com/spacedock-dev/spacedock@v0.27.2" - if len(piOps.piInstalls) != 1 || piOps.piInstalls[0] != want { - t.Fatalf("stamped binary must install %q, got %v", want, piOps.piInstalls) - } - }) - - t.Run("proxy-tagged build installs its tag", func(t *testing.T) { - withPiReleaseIdentity(t, "dev", "v0.27.2") - piOps := &fakePiRuntimeOps{lookPath: piHealthyPathFixtures()} - var stdout, stderr bytes.Buffer - if code := runInitWithPi(context.Background(), []string{"--host", "pi"}, &fakeHost{}, piOps, piTestEnv(pkg, t.TempDir()), &stdout, &stderr); code != 0 { - t.Fatalf("exit=%d stderr=%q", code, stderr.String()) - } - want := "git:github.com/spacedock-dev/spacedock@v0.27.2" - if len(piOps.piInstalls) != 1 || piOps.piInstalls[0] != want { - t.Fatalf("proxy build must pin its tag: want %q, got %v", want, piOps.piInstalls) - } - }) - - t.Run("dev sentinel keeps the floating source", func(t *testing.T) { - withPiReleaseIdentity(t, "dev", "(devel)") - piOps := &fakePiRuntimeOps{lookPath: piHealthyPathFixtures()} - var stdout, stderr bytes.Buffer - if code := runInitWithPi(context.Background(), []string{"--host", "pi"}, &fakeHost{}, piOps, piTestEnv(pkg, t.TempDir()), &stdout, &stderr); code != 0 { - t.Fatalf("exit=%d stderr=%q", code, stderr.String()) - } - if len(piOps.piInstalls) != 1 || piOps.piInstalls[0] != piSpacedockPackageSource { - t.Fatalf("dev build must keep the bare floating source, got %v", piOps.piInstalls) - } - }) - - t.Run("plugin-dir override wins and never uses the release source", func(t *testing.T) { - withPiReleaseIdentity(t, "0.27.2", "(devel)") - piOps := &fakePiRuntimeOps{lookPath: piHealthyPathFixtures()} - var stdout, stderr bytes.Buffer - if code := runInitWithPi(context.Background(), []string{"--host", "pi", "--plugin-dir", "/checkout"}, &fakeHost{}, piOps, piTestEnv(pkg, t.TempDir()), &stdout, &stderr); code != 0 { - t.Fatalf("exit=%d stderr=%q", code, stderr.String()) - } - if len(piOps.piInstalls) != 1 || piOps.piInstalls[0] != "/checkout" { - t.Fatalf("plugin dir must be the install source, got %v", piOps.piInstalls) - } - }) -} - -func TestPiFrontDoorRepairsUnpinnedPackageAndLaunches(t *testing.T) { - repo, pkg := piRepairFixture(t) - // The released v0.27.2 incident shape: registered but UNPINNED. - repaired := healthyPiPackageStatus() - ops := piRepairOps(repo, pkg, piPackageStatus{registered: true, ensignDiscoverable: false, source: "git:github.com/spacedock-dev/spacedock"}, &repaired) - withPiReleaseIdentity(t, "0.27.2", "(devel)") - code, _, stderr := runPiWith(t, ops, pkg, nil) - - if code != 0 { - t.Fatalf("exit=%d stderr=%q", code, stderr) - } - if len(ops.launched) == 0 { - t.Fatalf("expected launch after repair; stderr=%q", stderr) - } - want := "git:github.com/spacedock-dev/spacedock@v0.27.2" - if len(ops.piInstalls) != 1 || ops.piInstalls[0] != want { - t.Fatalf("expected exactly one install of %q, got %v", want, ops.piInstalls) - } - // Initial check + one post-repair recheck; never a second install. - if ops.statusCalls != 2 { - t.Fatalf("expected 2 status reads (initial + recheck), got %d", ops.statusCalls) - } -} - -func TestPiFrontDoorRepairsWrongReleaseLine(t *testing.T) { - repo, pkg := piRepairFixture(t) - // The incident: @v0.28.0-pre1 installed under a v0.27.2-identity binary. - wrongLine := healthyPiPackageStatus() - wrongLine.source = "git:github.com/spacedock-dev/spacedock@v0.28.0-pre1" - repaired := healthyPiPackageStatus() - repaired.source = "git:github.com/spacedock-dev/spacedock@v0.27.2" - ops := piRepairOps(repo, pkg, wrongLine, &repaired) - withPiReleaseIdentity(t, "0.27.2", "(devel)") - code, _, stderr := runPiWith(t, ops, pkg, nil) - - if code != 0 { - t.Fatalf("exit=%d stderr=%q", code, stderr) - } - if want := "git:github.com/spacedock-dev/spacedock@v0.27.2"; len(ops.piInstalls) != 1 || ops.piInstalls[0] != want { - t.Fatalf("wrong-line package must repair to the binary's own ref %q, got %v", want, ops.piInstalls) - } - if len(ops.launched) == 0 { - t.Fatalf("expected launch after wrong-line repair") - } -} - -func TestPiFrontDoorRefusesWhenRepairInstallFails(t *testing.T) { - repo, pkg := piRepairFixture(t) - ops := piRepairOps(repo, pkg, piPackageStatus{}, nil) - ops.piInstallErr = errors.New("clone failed") - withPiReleaseIdentity(t, "0.27.2", "(devel)") - code, _, stderr := runPiWith(t, ops, pkg, nil) - - if code != 1 { - t.Fatalf("exit=%d want 1", code) - } - // AC-4: one install attempt, one recheck, no launch, actionable error. - if len(ops.piInstalls) != 1 || len(ops.launched) != 0 { - t.Fatalf("want 1 install and no launch: installs=%v launched=%v", ops.piInstalls, ops.launched) - } - if ops.statusCalls != 2 { - t.Fatalf("expected exactly one recheck after the repair, got %d status reads", ops.statusCalls) - } - if !strings.Contains(stderr, "package repair install failed") || !strings.Contains(stderr, "spacedock doctor --host pi") { - t.Fatalf("actionable error missing; stderr=%q", stderr) - } -} - -func TestPiFrontDoorRefusesAfterIneffectiveRepair(t *testing.T) { - repo, pkg := piRepairFixture(t) - stillUnpinned := piPackageStatus{registered: true, ensignDiscoverable: false, source: "git:github.com/spacedock-dev/spacedock"} - ops := piRepairOps(repo, pkg, piPackageStatus{registered: true, source: "git:github.com/spacedock-dev/spacedock"}, &stillUnpinned) - withPiReleaseIdentity(t, "0.27.2", "(devel)") - code, _, _ := runPiWith(t, ops, pkg, nil) - - if code != 1 { - t.Fatalf("exit=%d want 1", code) - } - // AC-4: no second install attempt, no launch after a remaining mismatch. - if len(ops.piInstalls) != 1 || len(ops.launched) != 0 { - t.Fatalf("one install, no launch expected: installs=%v launched=%v", ops.piInstalls, ops.launched) - } -} - -func TestPiFrontDoorSuppressedRepairs(t *testing.T) { - t.Run("non-git healthy entry never repairs", func(t *testing.T) { - repo, pkg := piRepairFixture(t) - userEntry := piPackageStatus{registered: true, ensignDiscoverable: true, source: "file:/x", packageRoot: "/x"} - ops := piRepairOps(repo, pkg, userEntry, nil) - withPiReleaseIdentity(t, "0.27.2", "(devel)") - code, _, stderr := runPiWith(t, ops, pkg, nil) - if code != 0 { - t.Fatalf("exit=%d stderr=%q", code, stderr) - } - if len(ops.piInstalls) != 0 { - t.Fatalf("user-managed entry must not be repaired: %v", ops.piInstalls) - } - }) - - t.Run("dev-sentinel binary never repairs", func(t *testing.T) { - repo, pkg := piRepairFixture(t) - ops := piRepairOps(repo, pkg, piPackageStatus{}, nil) - withPiReleaseIdentity(t, "dev", "(devel)") - code, _, stderr := runPiWith(t, ops, pkg, nil) - // A dev build has no pin target: the pre-existing refusal stands. - if code != 1 { - t.Fatalf("exit=%d want 1; stderr=%q", code, stderr) - } - if len(ops.piInstalls) != 0 { - t.Fatalf("dev build must not repair-install: %v", ops.piInstalls) - } - }) - - t.Run("plugin-dir override suppresses the repair", func(t *testing.T) { - repo, pkg := piRepairFixture(t) - ops := piRepairOps(repo, pkg, piPackageStatus{}, nil) - withPiReleaseIdentity(t, "0.27.2", "(devel)") - var stdout, stderr bytes.Buffer - code := runPi(context.Background(), []string{"review this", "--plugin-dir", repo}, t.TempDir(), piTestEnv(pkg, t.TempDir()), ops, &stdout, &stderr) - if code != 0 { - t.Fatalf("exit=%d stderr=%q", code, stderr.String()) - } - if len(ops.piInstalls) != 0 { - t.Fatalf("dev override suppresses the repair: %v", ops.piInstalls) - } - }) -} - -func TestPiFrontDoorHealthyPinnedPackageDoesNotRepair(t *testing.T) { - repo, pkg := piRepairFixture(t) - pinned := healthyPiPackageStatus() - pinned.source = "git:github.com/spacedock-dev/spacedock@v0.27.2" - ops := piRepairOps(repo, pkg, pinned, nil) - withPiReleaseIdentity(t, "0.27.2", "(devel)") - code, _, stderr := runPiWith(t, ops, pkg, nil) - - if code != 0 { - t.Fatalf("exit=%d stderr=%q", code, stderr) - } - if len(ops.piInstalls) != 0 { - t.Fatalf("healthy pinned package must not repair: %v", ops.piInstalls) - } - if len(ops.launched) == 0 { - t.Fatalf("expected launch") - } -} diff --git a/internal/cli/pi_package_source_test.go b/internal/cli/pi_package_source_test.go new file mode 100644 index 000000000..65901d104 --- /dev/null +++ b/internal/cli/pi_package_source_test.go @@ -0,0 +1,94 @@ +package cli + +import "testing" + +// TestPiReleaseRefDerivation pins the ordered ref derivation (linker stamp, +// then build-info main version, then the dev sentinel) for every identity +// class the derivation must distinguish. Falsifier: changing any derivation +// arm flips the row for that identity — notably the proxy-build row (the +// go-install @vX.Y.Z build that must pin) and the pseudo-version rows (go +// install @next resolutions, which must float). +func TestPiReleaseRefDerivation(t *testing.T) { + const pseudo1 = "v0.28.0-pre0.0.20260828120000-abcdef123456" // @next over a pre tag + const pseudo2 = "v0.27.1-0.20260828120000-abcdef123456" // no base tag + for _, tc := range []struct { + name, stamp, buildInfo, want string + }{ + {"linker stamp wins", "0.27.2", "v0.26.0", "v0.27.2"}, + {"v-prefixed stamp normalizes", "v0.27.2", "", "v0.27.2"}, + {"prerelease stamp", "0.28.0-pre1", "(devel)", "v0.28.0-pre1"}, + {"proxy build pins its tag", "dev", "v0.27.2", "v0.27.2"}, + {"checkout build is the dev sentinel", "dev", "(devel)", ""}, + {"empty build info is the dev sentinel", "dev", "", ""}, + {"next pseudo-version floats", "dev", pseudo1, ""}, + {"plain pseudo-version floats", "dev", pseudo2, ""}, + {"non-semver build info floats", "dev", "not-a-version", ""}, + {"empty stamp uses build info", "", "v0.27.2", "v0.27.2"}, + } { + if got := piReleaseRefFrom(tc.stamp, tc.buildInfo); got != tc.want { + t.Fatalf("%s: piReleaseRefFrom(%q,%q)=%q want %q", tc.name, tc.stamp, tc.buildInfo, got, tc.want) + } + } +} + +// TestPiPinnedSourceAndRefParsing pins the source-string construction and the +// git/non-git entry classifier the repair trigger and AC-5's no-clobber +// guarantee depend on. Falsifier: dropping the @ref suffix, floating a pinned +// ref, or classifying a git entry as non-git (or vice versa) flips a row. +func TestPiPinnedSourceAndRefParsing(t *testing.T) { + const repo = "git:github.com/spacedock-dev/spacedock" + for _, tc := range []struct{ ref, want string }{ + {"v0.27.2", repo + "@v0.27.2"}, + {"v0.28.0-pre0", repo + "@v0.28.0-pre0"}, + {"", repo}, // dev sentinel keeps the bare floating source + } { + if got := piPinnedSource(tc.ref); got != tc.want { + t.Fatalf("piPinnedSource(%q)=%q want %q", tc.ref, got, tc.want) + } + } + for _, tc := range []struct { + source, ref string + isGit bool + }{ + {"git:github.com/spacedock-dev/spacedock", "", true}, + {"git:github.com/spacedock-dev/spacedock@v0.27.2", "v0.27.2", true}, + {"file:/tmp/repo", "", false}, + {"npm:spacedock", "", false}, + {"/usr/local/spacedock", "", false}, + {"", "", false}, + } { + if ref, ok := piGitSourceRef(tc.source); ref != tc.ref || ok != tc.isGit { + t.Fatalf("piGitSourceRef(%q)=(%q,%v) want (%q,%v)", tc.source, ref, ok, tc.ref, tc.isGit) + } + } +} + +// TestPiPackageNeedsRepairTable pins the trigger decision: a release-shaped +// binary repairs a missing, unpinned, or wrong-ref git entry; its own ref, +// any non-git entry, and the dev sentinel never trigger. +func TestPiPackageNeedsRepairTable(t *testing.T) { + missing := piPackageStatus{} + unpinned := piPackageStatus{registered: true, source: "git:github.com/spacedock-dev/spacedock"} + wrongLine := piPackageStatus{registered: true, source: "git:github.com/spacedock-dev/spacedock@v0.28.0-pre1"} + ownRef := piPackageStatus{registered: true, source: "git:github.com/spacedock-dev/spacedock@v0.27.2"} + userEntry := piPackageStatus{registered: true, source: "file:/x"} + for _, tc := range []struct { + name string + st piPackageStatus + relRef string + want bool + wantReason string + }{ + {"missing entry, release-shaped", missing, "v0.27.2", true, "missing"}, + {"unpinned git entry", unpinned, "v0.27.2", true, "unpinned"}, + {"wrong release line", wrongLine, "v0.27.2", true, "wrong release line"}, + {"pinned to own ref", ownRef, "v0.27.2", false, ""}, + {"non-git entry is user-managed", userEntry, "v0.27.2", false, ""}, + {"dev sentinel never repairs", missing, "", false, ""}, + } { + got, reason := piPackageNeedsRepair(tc.st, tc.relRef) + if got != tc.want || (got && reason != tc.wantReason) { + t.Fatalf("%s: repair=(%v,%q) want (%v,%q)", tc.name, got, reason, tc.want, tc.wantReason) + } + } +} diff --git a/internal/ensigncycle/pi_frontdoor_pin_live_test.go b/internal/ensigncycle/pi_frontdoor_pin_live_test.go deleted file mode 100644 index dda651fbb..000000000 --- a/internal/ensigncycle/pi_frontdoor_pin_live_test.go +++ /dev/null @@ -1,96 +0,0 @@ -//go:build live - -package ensigncycle - -import ( - "encoding/json" - "os" - "os/exec" - "path/filepath" - "strings" - "testing" -) - -// piStampedSpacedockBinary builds the checkout binary as a release-shaped -// artifact: the goreleaser stamp form of the checkout manifest's own version -// (0.28.0-pre0, an existing tag), so the pinned install source resolves to a -// real remote tag in the live run. -func piStampedSpacedockBinary(t *testing.T, repo string) string { - t.Helper() - out := filepath.Join(t.TempDir(), "spacedock") - cmd := exec.Command("go", "build", "-o", out, - "-ldflags", "-X github.com/spacedock-dev/spacedock/internal/cli.Version=0.28.0-pre0", - "./cmd/spacedock") - cmd.Dir = repo - if b, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("build stamped spacedock for the pinned-package live journey: %v\n%s", err, b) - } - return out -} - -//spacedock:live-proof id=pi-front-door-pinned-package lane=pi-live -func TestLivePiFrontDoorInstallsPinnedPackage(t *testing.T) { - repo := repoRoot(t) - piSubagentsRoot := piSubagentsPackageRoot(t) - binary := piStampedSpacedockBinary(t, repo) - - // The incident fixture: a fresh pi-home whose settings.json registers NO - // Spacedock package (the post-skew state the v0.27.2 abort left behind). - piHome := t.TempDir() - sessionDir := t.TempDir() - cleanHome := t.TempDir() - decision := seedPiLiveAuth(t, piHome, os.Getenv("HOME"), os.Getenv("CODEX_AUTH_JSON"), os.Getenv("OPENAI_API_KEY"), os.Getenv("SPACEDOCK_PI_LIVE_REQUIRED")) - artifactDir := filepath.Join(piLiveArtifactDir(t, "pi-frontdoor-pin"), "run") - if err := os.MkdirAll(filepath.Join(artifactDir, "sessions"), 0o755); err != nil { - t.Fatal(err) - } - env := piLiveEnvForAuth(piHome, sessionDir, cleanHome, filepath.Dir(binary), piSubagentsRoot, os.Getenv("OPENAI_API_KEY"), decision.mode) - model := piLiveChildModel(decision) - - // The ordinary installed front door: no --plugin-dir, no SPACEDOCK_REPO_ROOT. - runPiLiveCommand(t, artifactDir, repo, env, binary, - "pi", - "Reply with the single word READY.", - "--", - "--print", - "--model", model, - "--session-dir", filepath.Join(artifactDir, "sessions"), - ) - - // AC-1 durable proof: the front door's one repair registered the binary's - // own pinned ref. - entry := piSpacedockSettingsEntry(t, piHome) - want := "git:github.com/spacedock-dev/spacedock@v0.28.0-pre0" - if !strings.Contains(entry, want) { - t.Fatalf("pinned package entry %q not registered after the repair; got entry %q", want, entry) - } -} - -// piSpacedockSettingsEntry returns the spacedock package entry (string or -// {"source": ...} form) from the pi agent dir's settings.json. -func piSpacedockSettingsEntry(t *testing.T, piHome string) string { - t.Helper() - data, err := os.ReadFile(filepath.Join(piHome, "settings.json")) - if err != nil { - t.Fatalf("read pi settings: %v", err) - } - var settings struct { - Packages []json.RawMessage `json:"packages"` - } - if err := json.Unmarshal(data, &settings); err != nil { - t.Fatalf("parse pi settings: %v", err) - } - for _, raw := range settings.Packages { - var s string - if json.Unmarshal(raw, &s) == nil && strings.Contains(s, "spacedock") { - return s - } - var obj struct { - Source string `json:"source"` - } - if json.Unmarshal(raw, &obj) == nil && strings.Contains(obj.Source, "spacedock") { - return obj.Source - } - } - return "" -} From 284299e3b0e038326f4ae0545b16bbbdf2cac619 Mon Sep 17 00:00:00 2001 From: CL Kao Date: Fri, 28 Aug 2026 18:46:07 -0700 Subject: [PATCH 5/5] pi: refuse launch after failed package repair --- internal/cli/pi.go | 28 +++++++++++++++++--------- internal/cli/pi_package_source.go | 2 +- internal/cli/pi_package_source_test.go | 2 ++ 3 files changed, 22 insertions(+), 10 deletions(-) diff --git a/internal/cli/pi.go b/internal/cli/pi.go index 99bbf9e49..e126ec2d9 100644 --- a/internal/cli/pi.go +++ b/internal/cli/pi.go @@ -215,8 +215,18 @@ func runPi(ctx context.Context, args []string, dir string, env []string, ops piR // overrides (--plugin-dir / SPACEDOCK_REPO_ROOT own the package surface // for their run). The recheck below, never a second attempt, decides // launch readiness. - if piRepairAttempt(ops, cfg, check, stderr) { + if attempted, installErr := piRepairAttempt(ops, cfg, check, stderr); attempted { + if installErr != nil { + fmt.Fprint(stderr, "spacedock pi: package repair failed; run `spacedock doctor --host pi` or `spacedock install --host pi`\n") + return 1 + } check = checkPiRuntime(ops, cfg) + ref, isGit := piGitSourceRef(check.packageStatus.source) + if !check.packageStatus.registered || !isGit || ref != piReleaseRef() { + fmt.Fprint(stderr, "spacedock pi: package repair did not install the required release; run `spacedock doctor --host pi` or `spacedock install --host pi`\n") + printPiDoctorReport(stdout, check) + return 1 + } } if !piRuntimeLaunchReady(check) { fmt.Fprint(stderr, "spacedock pi: Pi runtime is not ready; run `spacedock doctor --host pi` or `spacedock install --host pi`\n") @@ -631,14 +641,14 @@ func piRuntimeLaunchReady(c piCheckResult) bool { // piRepairAttempt runs the `spacedock pi` front door's one Spacedock-package // repair (pin-pi-package-to-binary-release). It reports whether an install -// was attempted, so the caller rechecks the runtime before the launch gate. -// Exactly one install runs per launch; the recheck — never a second attempt — -// decides launch readiness. -func piRepairAttempt(ops piRuntimeOps, cfg piRuntimeConfig, check piCheckResult, stderr io.Writer) bool { +// was attempted and returns its install error. Exactly one install runs per +// launch; after a successful install, the caller rechecks the package source +// and runtime before the launch gate. +func piRepairAttempt(ops piRuntimeOps, cfg piRuntimeConfig, check piCheckResult, stderr io.Writer) (bool, error) { // A declared development override owns the package surface for its run: // the repair never fights --plugin-dir / SPACEDOCK_REPO_ROOT. if cfg.repoRoot != "" { - return false + return false, nil } // The repair cannot help when pi itself is unavailable — and `pi install` // needs the pi binary anyway. (The failure being outside the package gate @@ -646,12 +656,12 @@ func piRepairAttempt(ops piRuntimeOps, cfg piRuntimeConfig, check piCheckResult, // the package entry, including the proactive wrong-line case where the // package currently "launches" but the FO binary gate would abort.) if !check.piBinOK { - return false + return false, nil } ref := piReleaseRef() needed, reason := piPackageNeedsRepair(check.packageStatus, ref) if !needed { - return false + return false, nil } source := piPinnedSource(ref) fmt.Fprintf(stderr, "spacedock pi: Spacedock package %s; installing pinned source %s\n", reason, source) @@ -662,7 +672,7 @@ func piRepairAttempt(ops piRuntimeOps, cfg piRuntimeConfig, check piCheckResult, if err != nil { fmt.Fprintf(stderr, "spacedock pi: package repair install failed: %v\n", err) } - return true // one attempt spent; the caller's recheck decides the launch + return true, err } func piDoctorHealthy(c piCheckResult) bool { diff --git a/internal/cli/pi_package_source.go b/internal/cli/pi_package_source.go index 2e4416da4..9b4ddbb8b 100644 --- a/internal/cli/pi_package_source.go +++ b/internal/cli/pi_package_source.go @@ -94,7 +94,7 @@ func piSemverTag(v string) string { // the base-tag form vX.Y.Z-pre.0.-). Both end in a 14-digit // timestamp followed by the commit hash; they resolve no tag, so they carry // no release identity: the dev sentinel applies. -var piPseudoVersion = regexp.MustCompile(`\d{14}-[0-9a-f]{7,12}$`) +var piPseudoVersion = regexp.MustCompile(`\d{14}-[0-9a-f]{7,12}(?:\+[^ ]+)?$`) // piPinnedSource returns the install source for the Spacedock Pi package given // the binary's release ref: a ref-pinned source for a release-shaped binary diff --git a/internal/cli/pi_package_source_test.go b/internal/cli/pi_package_source_test.go index 65901d104..3cc66f2b7 100644 --- a/internal/cli/pi_package_source_test.go +++ b/internal/cli/pi_package_source_test.go @@ -11,6 +11,7 @@ import "testing" func TestPiReleaseRefDerivation(t *testing.T) { const pseudo1 = "v0.28.0-pre0.0.20260828120000-abcdef123456" // @next over a pre tag const pseudo2 = "v0.27.1-0.20260828120000-abcdef123456" // no base tag + const dirtyPseudo = "v0.28.0-pre0.0.20260828165724-81e3386e8234+dirty" for _, tc := range []struct { name, stamp, buildInfo, want string }{ @@ -22,6 +23,7 @@ func TestPiReleaseRefDerivation(t *testing.T) { {"empty build info is the dev sentinel", "dev", "", ""}, {"next pseudo-version floats", "dev", pseudo1, ""}, {"plain pseudo-version floats", "dev", pseudo2, ""}, + {"dirty checkout pseudo-version floats", "dev", dirtyPseudo, ""}, {"non-semver build info floats", "dev", "not-a-version", ""}, {"empty stamp uses build info", "", "v0.27.2", "v0.27.2"}, } {