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
279 changes: 279 additions & 0 deletions internal/dispatch/build_pi_host_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,14 @@ package dispatch

import (
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"

"github.com/spacedock-dev/spacedock/internal/piruntime"
)

func TestBuildPiHostPromptShape(t *testing.T) {
Expand Down Expand Up @@ -68,6 +72,281 @@ func TestBuildPiHostPromptShape(t *testing.T) {
}
}

func TestBuildPiHostArtifactCarriesCanonicalStageFactsThroughPiWrapper(t *testing.T) {
root := t.TempDir()
stateDir := filepath.Join(root, "state-checkout")
writeFile(t, filepath.Join(root, "README.md"), readmeWorktree(true))
if err := os.MkdirAll(stateDir, 0o755); err != nil {
t.Fatal(err)
}
worktreeRel := ".worktrees/spacedock-ensign-canonical-stage"
worktreePath := filepath.Join(root, worktreeRel)
if err := os.MkdirAll(worktreePath, 0o755); err != nil {
t.Fatal(err)
}
entityPath := filepath.Join(stateDir, "canonical-stage", "index.md")
writeFile(t, entityPath, entityFM("Canonical Stage", "implementation", worktreeRel))
gitInit(t, root)

checklist := []string{"- keep the builder assignment", "- write the stage report"}
stdin := mergeStdin(map[string]any{
"schema_version": 2,
"entity_path": entityPath,
"workflow_dir": root,
"stage": "implementation",
"checklist": checklist,
"bare_mode": true,
"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)
}
var out struct {
Name *string `json:"name"`
Description string `json:"description"`
DispatchFile string `json:"dispatch_file_path"`
Prompt string `json:"prompt"`
}
if err := json.Unmarshal([]byte(native.stdout), &out); err != nil {
t.Fatalf("stdout is not build JSON: %v\n%s", err, native.stdout)
}
if out.Name != nil {
t.Fatalf("bare Pi dispatch should not require team-style worker name, got %q", *out.Name)
}
if out.Description != "Canonical Stage: implementation" {
t.Fatalf("description = %q", out.Description)
}
if !strings.Contains(out.DispatchFile, "spacedock-ensign-canonical-stage-implementation.md") {
t.Fatalf("dispatch file path does not carry builder-derived slug/stage: %q", out.DispatchFile)
}

wrapped := piruntime.SubagentStageDispatch(out.Prompt, "implementation", "canonical-stage implementation")
if wrapped.Context != "fresh" {
t.Fatalf("Pi wrapper context = %q, want fresh", wrapped.Context)
}
if wrapped.Task != out.Prompt {
t.Fatalf("Pi wrapper replaced the dispatch-build prompt:\nwant: %s\n got: %s", out.Prompt, wrapped.Task)
}
if strings.Contains(wrapped.Task, "acceptance") {
t.Fatalf("Pi stage wrapper task unexpectedly contains same-agent acceptance contract: %q", wrapped.Task)
}

body := readDispatchBody(t, out.DispatchFile)
for _, want := range []string{
"You are working on: Canonical Stage",
"Stage: implementation",
"Read the entity file at " + entityPath,
"Your working directory for CODE is " + worktreePath,
"All CODE reads and writes MUST use paths under " + worktreePath,
"- keep the builder assignment",
"- write the stage report",
} {
if !strings.Contains(body, want) {
t.Fatalf("Pi dispatch artifact missing builder-derived fact %q:\n%s", want, body)
}
}
assertRenderedStageDefCommand(t, body, root, "implementation")
if !strings.Contains(wrapped.Task, out.DispatchFile) {
t.Fatalf("Pi wrapper does not forward dispatch file path %s in task %q", out.DispatchFile, wrapped.Task)
}
}

func TestPiStageDispatchSmokeUsesBuildArtifactThroughWrapper(t *testing.T) {
root := t.TempDir()
stateDir := filepath.Join(root, "state-checkout")
writeFile(t, filepath.Join(root, "README.md"), readmeWorktree(true))
if err := os.MkdirAll(stateDir, 0o755); err != nil {
t.Fatal(err)
}
worktreeRel := ".worktrees/spacedock-ensign-smoke-stage"
worktreePath := filepath.Join(root, worktreeRel)
if err := os.MkdirAll(worktreePath, 0o755); err != nil {
t.Fatal(err)
}
entityPath := filepath.Join(stateDir, "smoke-stage", "index.md")
writeFile(t, entityPath, entityFM("Smoke Stage", "implementation", worktreeRel))
gitInit(t, stateDir)

checklist := []string{"- run the fixture Pi worker", "- append durable stage report"}
stdin := mergeStdin(map[string]any{
"schema_version": 2,
"entity_path": entityPath,
"workflow_dir": root,
"stage": "implementation",
"checklist": checklist,
"bare_mode": true,
"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)
}
var out struct {
DispatchFile string `json:"dispatch_file_path"`
Prompt string `json:"prompt"`
}
if err := json.Unmarshal([]byte(native.stdout), &out); err != nil {
t.Fatalf("stdout is not build JSON: %v\n%s", err, native.stdout)
}
wrapped := piruntime.SubagentStageDispatch(out.Prompt, "implementation", "z6 implementation fixback")
payload, err := json.Marshal(wrapped)
if err != nil {
t.Fatal(err)
}

cmd := exec.Command(os.Args[0], "-test.run=^TestPiStageDispatchSmokeFixtureWorker$", "-test.v")
cmd.Env = append(os.Environ(),
"SPACEDOCK_PI_FIXTURE_WORKER=1",
"SPACEDOCK_PI_WRAPPER_JSON="+string(payload),
"SPACEDOCK_PI_ENTITY_PATH="+entityPath,
"SPACEDOCK_PI_STATE_DIR="+stateDir,
"SPACEDOCK_PI_WORKFLOW_DIR="+root,
"SPACEDOCK_PI_WORKTREE_PATH="+worktreePath,
)
workerOut, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("fixture Pi worker process failed: %v\n%s", err, workerOut)
}

entityBody, err := os.ReadFile(entityPath)
if err != nil {
t.Fatal(err)
}
for _, want := range []string{
"## Stage Report: implementation fixback smoke",
"- process: fixture Pi worker exited 0",
"- dispatch_file: " + out.DispatchFile,
"- wrapper_context: fresh",
"- checklist_seen: run the fixture Pi worker; append durable stage report",
} {
if !strings.Contains(string(entityBody), want) {
t.Fatalf("entity report missing durable evidence %q:\n%s", want, entityBody)
}
}

logCmd := exec.Command("git", "-C", stateDir, "log", "--oneline", "--", entityPath)
logOut, err := logCmd.CombinedOutput()
if err != nil {
t.Fatalf("state git log failed: %v\n%s", err, logOut)
}
if !strings.Contains(string(logOut), "fixture Pi stage report") {
t.Fatalf("state git log missing fixture stage-report commit:\n%s", logOut)
}
statusCmd := exec.Command("git", "-C", stateDir, "status", "--short", "--", entityPath)
statusOut, err := statusCmd.CombinedOutput()
if err != nil {
t.Fatalf("state git status failed: %v\n%s", err, statusOut)
}
if strings.TrimSpace(string(statusOut)) != "" {
t.Fatalf("state entity should be clean after path-scoped commit:\n%s", statusOut)
}
}

func TestPiStageDispatchSmokeFixtureWorker(t *testing.T) {
if os.Getenv("SPACEDOCK_PI_FIXTURE_WORKER") != "1" {
return
}
var wrapped piruntime.SubagentDispatch
if err := json.Unmarshal([]byte(os.Getenv("SPACEDOCK_PI_WRAPPER_JSON")), &wrapped); err != nil {
t.Fatalf("wrapper JSON: %v", err)
}
if wrapped.Context != "fresh" {
t.Fatalf("wrapper context = %q, want fresh", wrapped.Context)
}
if strings.Contains(os.Getenv("SPACEDOCK_PI_WRAPPER_JSON"), "acceptance") {
t.Fatalf("wrapper unexpectedly serialized same-agent acceptance: %s", os.Getenv("SPACEDOCK_PI_WRAPPER_JSON"))
}

dispatchPath, err := piDispatchFileFromTask(wrapped.Task)
if err != nil {
t.Fatal(err)
}
dispatchBody, err := os.ReadFile(dispatchPath)
if err != nil {
t.Fatalf("read dispatch artifact: %v", err)
}
entityPath := os.Getenv("SPACEDOCK_PI_ENTITY_PATH")
workflowDir := os.Getenv("SPACEDOCK_PI_WORKFLOW_DIR")
worktreePath := os.Getenv("SPACEDOCK_PI_WORKTREE_PATH")
dispatchText := string(dispatchBody)
for _, want := range []string{
"You are working on: Smoke Stage",
"Stage: implementation",
"Read the entity file at " + entityPath,
"Your working directory for CODE is " + worktreePath,
"- run the fixture Pi worker",
"- append durable stage report",
} {
if !strings.Contains(dispatchText, want) {
t.Fatalf("dispatch artifact missing %q:\n%s", want, dispatchBody)
}
}
assertRenderedStageDefCommand(t, dispatchText, workflowDir, "implementation")

report := fmt.Sprintf(`
## Stage Report: implementation fixback smoke

- process: fixture Pi worker exited 0
- dispatch_file: %s
- wrapper_context: %s
- wrapper_phase: %s
- wrapper_label: %s
- checklist_seen: run the fixture Pi worker; append durable stage report
`, dispatchPath, wrapped.Context, wrapped.Phase, wrapped.Label)
f, err := os.OpenFile(entityPath, os.O_APPEND|os.O_WRONLY, 0)
if err != nil {
t.Fatalf("open entity report: %v", err)
}
if _, err := f.WriteString(report); err != nil {
_ = f.Close()
t.Fatalf("append entity report: %v", err)
}
if err := f.Close(); err != nil {
t.Fatalf("close entity report: %v", err)
}

stateDir := os.Getenv("SPACEDOCK_PI_STATE_DIR")
for _, args := range [][]string{
{"-c", "user.email=t@t", "-c", "user.name=t", "add", entityPath},
{"-c", "user.email=t@t", "-c", "user.name=t", "commit", "-q", "-m", "fixture Pi stage report", "--", entityPath},
} {
cmd := exec.Command("git", append([]string{"-C", stateDir}, args...)...)
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git %v: %v\n%s", args, err, out)
}
}
}

func assertRenderedStageDefCommand(t *testing.T, body, workflowDir, stage string) {
t.Helper()
semantic := "dispatch show-stage-def --workflow-dir " + shlexQuote(workflowDir) + " --stage " + stage
for _, want := range []string{
"spacedock_launcher() {",
"spacedock_launcher " + semantic,
} {
if !strings.Contains(body, want) {
t.Fatalf("dispatch artifact missing rendered stage-definition command %q:\n%s", want, body)
}
}
}

func piDispatchFileFromTask(task string) (string, error) {
const prefix = "Read "
if !strings.HasPrefix(task, prefix) {
return "", fmt.Errorf("Pi task does not start with %q: %q", prefix, task)
}
rest := strings.TrimPrefix(task, prefix)
marker := " and treat its content as your assignment"
idx := strings.Index(rest, marker)
if idx < 0 {
return "", fmt.Errorf("Pi task does not forward read-dispatch-file assignment: %q", task)
}
return rest[:idx], nil
}

func TestBuildPiHostPreservesSplitRootEntityPath(t *testing.T) {
root := t.TempDir()
stateDir := filepath.Join(root, "state-checkout")
Expand Down
25 changes: 25 additions & 0 deletions internal/piruntime/subagents.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// ABOUTME: Pi-subagents assignment wrappers for Spacedock stage dispatches.
// ABOUTME: Keeps Pi transport fields additive around dispatch-build artifacts.
package piruntime

// SubagentDispatch is the Spacedock-owned subset of the pi-subagents stage
// dispatch wrapper. The canonical assignment remains the dispatch-build artifact;
// this wrapper only adds Pi transport metadata.
type SubagentDispatch struct {
Task string `json:"task"`
Context string `json:"context"`
Phase string `json:"phase,omitempty"`
Label string `json:"label,omitempty"`
}

// SubagentStageDispatch wraps an already-built dispatch artifact prompt/content
// for pi-subagents. It intentionally has no acceptance field: stage acceptance is
// owned by the canonical assignment/checklist and independent Spacedock validation.
func SubagentStageDispatch(assignment, phase, label string) SubagentDispatch {
return SubagentDispatch{
Task: assignment,
Context: "fresh",
Phase: phase,
Label: label,
}
}
32 changes: 32 additions & 0 deletions internal/piruntime/subagents_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// ABOUTME: Pi-subagents stage dispatch wrapper invariants.
// ABOUTME: Proves the wrapper adds only transport fields around canonical assignments.
package piruntime

import (
"encoding/json"
"strings"
"testing"
)

func TestSubagentStageDispatchAddsOnlyPiTransportFields(t *testing.T) {
assignment := "Read /tmp/spacedock-dispatches/spacedock-ensign-fixture-implementation.md and treat its content as your assignment."

wrapped := SubagentStageDispatch(assignment, "implementation", "fixture implementation")
if wrapped.Context != "fresh" {
t.Fatalf("context = %q, want fresh", wrapped.Context)
}
if wrapped.Task != assignment {
t.Fatalf("wrapper rewrote assignment:\nwant: %s\n got: %s", assignment, wrapped.Task)
}
if wrapped.Phase != "implementation" || wrapped.Label != "fixture implementation" {
t.Fatalf("phase/label not preserved: %#v", wrapped)
}

payload, err := json.Marshal(wrapped)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(payload), "acceptance") {
t.Fatalf("stage wrapper must not contain same-agent acceptance contract: %s", payload)
}
}
6 changes: 4 additions & 2 deletions skills/first-officer/references/pi-first-officer-runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,11 @@ The Spacedock contract talks in terms of dispatch, completion, follow-up, and sh

## Dispatch

Use `spacedock dispatch build` with `host: "pi"` in the input JSON. Forward the emitted dispatch file prompt to the Pi worker without rewriting it into Claude syntax. For the first slice, dispatch should normally run with `bare_mode: true`: the Pi subagent result is the completion signal, so no `team_name` is required.
Use `spacedock dispatch build` with `host: "pi"` in the input JSON. The build artifact is the assignment source of truth: it carries the entity slug/name, entity path, workflow directory, target stage, stage definition fetch command, worktree path when applicable, completion checklist, and completion-signal wording. Forward the emitted dispatch file prompt or dispatch file content to the Pi worker without composing a replacement assignment and without rewriting it into Claude syntax. For the first slice, dispatch should normally run with `bare_mode: true`: the Pi subagent result is the completion signal, so no `team_name` is required.

A Pi first officer may dispatch via `subagent(...)` when that tool is available. The task must include the emitted dispatch-file prompt or the dispatch file content, the workflow directory, the entity path, and the completion checklist. The child must load the Spacedock ensign skill and Pi ensign runtime adapter before working.
A Pi first officer may dispatch via `subagent(...)` when that tool is available. The wrapper may add only Pi transport metadata around the build artifact: `context: "fresh"` plus optional human-facing `phase` / `label` values. Those wrapper fields are additive; they must not redefine or replace the builder-derived slug/name, workflow directory, entity path, target stage, worktree path, completion checklist, or completion contract. The child must load the Spacedock ensign skill and Pi ensign runtime adapter before working.

Manual Pi prompt composition is a break-glass fallback only when `spacedock dispatch build` is unavailable, exits non-zero, or the developer is explicitly debugging the dispatch builder. Any fallback must record the builder failure or unavailability reason and include the minimum canonical schema facts so the degraded dispatch is auditable.

For Spacedock stage dispatches through `pi-subagents`, call `subagent(...)` with explicit `context: "fresh"`. Do not rely on the worker agent's default context; Spacedock stage workers must be seeded by the task prompt/dispatch content, workflow directory, entity file, and completion checklist rather than inherited first-officer transcript context.

Expand Down
Loading
Loading