diff --git a/.github/workflows/runtime-live-e2e.yml b/.github/workflows/runtime-live-e2e.yml index 891b0a087..1a5e4768f 100644 --- a/.github/workflows/runtime-live-e2e.yml +++ b/.github/workflows/runtime-live-e2e.yml @@ -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 ./internal/ensigncycle + gotestsum --jsonfile pi-front-door-smoke-detail.jsonl --format pkgname -- -tags live -count=1 -timeout 15m -run 'TestLivePiFrontDoorSmoke|TestLivePiNonSelfDescribingDispatch' ./internal/ensigncycle - name: Upload live artifacts if: always() diff --git a/docs/runtime-live-ci-registry.md b/docs/runtime-live-ci-registry.md index 4ba9c502d..550e5c879 100644 --- a/docs/runtime-live-ci-registry.md +++ b/docs/runtime-live-ci-registry.md @@ -313,6 +313,22 @@ 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/dispatch/build.go b/internal/dispatch/build.go index 955607fed..96eaeb022 100644 --- a/internal/dispatch/build.go +++ b/internal/dispatch/build.go @@ -896,12 +896,17 @@ func firstActionBlock(host string) string { if host == "pi" { return "## First action\n" + "\n" + - "Read this dispatch file directly and treat its content as your operating contract and assignment.\n" + + "Before anything else, load the ensign discipline: run `/skill:ensign` " + + "(Pi's skill-invoke slash command), or if that is unavailable, read " + + "`skills/ensign/SKILL.md` and its `references/` directly. This loads the " + + "shared ensign discipline (stage-report format, polling, worktree " + + "ownership, completion signal protocol).\n" + "\n" + - "This file contains the shared ensign discipline entry points (stage-report format, polling, " + - "worktree ownership, and completion protocol) plus the stage-specific assignment. " + - "Pi dispatch is delivered through a Pi-native substrate such as pi-subagents; the Pi subagent completion result " + - "is the completion signal observed by the first officer. Do not emit Claude team-tool calls.\n" + "Then read this dispatch file and treat its content as your " + + "stage-specific assignment. Pi dispatch is delivered through a Pi-native " + + "substrate such as pi-subagents; the Pi subagent completion result is the " + + "completion signal observed by the first officer. Do not emit Claude " + + "team-tool calls.\n" } return "## First action\n" + "\n" + diff --git a/internal/dispatch/build_json_ergonomics_test.go b/internal/dispatch/build_json_ergonomics_test.go index 586f7cf2e..ee4d04997 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 directly", "Pi subagent completion result", "Do not emit Claude team-tool calls"} { + for _, want := range []string{"read this dispatch file", "/skill:ensign", "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_pi_host_test.go b/internal/dispatch/build_pi_host_test.go index 30f686fa6..0fc0e5d98 100644 --- a/internal/dispatch/build_pi_host_test.go +++ b/internal/dispatch/build_pi_host_test.go @@ -61,7 +61,8 @@ func TestBuildPiHostPromptShape(t *testing.T) { } } for _, want := range []string{ - "Read this dispatch file directly", + "read this dispatch file", + "/skill:ensign", "Pi subagent completion result", "Do not emit Claude team-tool calls", } { diff --git a/internal/dispatch/build_stage_report_protocol_test.go b/internal/dispatch/build_stage_report_protocol_test.go new file mode 100644 index 000000000..40a607ac2 --- /dev/null +++ b/internal/dispatch/build_stage_report_protocol_test.go @@ -0,0 +1,68 @@ +// 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/pi_nonself_describing_build_test.go b/internal/ensigncycle/pi_nonself_describing_build_test.go new file mode 100644 index 000000000..ca6c06c78 --- /dev/null +++ b/internal/ensigncycle/pi_nonself_describing_build_test.go @@ -0,0 +1,59 @@ +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 new file mode 100644 index 000000000..fec0efb17 --- /dev/null +++ b/internal/ensigncycle/pi_nonself_describing_live_test.go @@ -0,0 +1,174 @@ +//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")) + } +}