Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/runtime-live-e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
16 changes: 16 additions & 0 deletions docs/runtime-live-ci-registry.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 10 additions & 5 deletions internal/dispatch/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -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" +
Expand Down
2 changes: 1 addition & 1 deletion internal/dispatch/build_json_ergonomics_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
3 changes: 2 additions & 1 deletion internal/dispatch/build_pi_host_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
} {
Expand Down
68 changes: 68 additions & 0 deletions internal/dispatch/build_stage_report_protocol_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
59 changes: 59 additions & 0 deletions internal/ensigncycle/pi_nonself_describing_build_test.go
Original file line number Diff line number Diff line change
@@ -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"
}
174 changes: 174 additions & 0 deletions internal/ensigncycle/pi_nonself_describing_live_test.go
Original file line number Diff line number Diff line change
@@ -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"))
}
}
Loading