Skip to content
Draft
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
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ When a worker completes:

If not gated: terminal → merge; else decide reuse-or-fresh.

**A completed non-gated, non-terminal stage is not a stopping point.** After verifying the report, the FO MUST advance the entity to the next stage and dispatch it (reuse-or-fresh per the dispatch module's reuse conditions) BEFORE ending its turn. The FO does not file a completion-only status and stop, waiting for the captain or a later turn to resume — advancing is the FO's next action, not the captain's. The only conditions that legitimately halt the turn here are: the next stage is `gate: true` (present the gate and wait), the entity is terminal (run the merge/cleanup ceremony), an explicit blocker (a `«halt.rebase-conflict»`, an unmet clarification), or a captain decision the contract requires. Absent one of those, stopping after a completion-only report is a contract violation.
**A completed non-gated, non-terminal stage is not a stopping point.** After verifying the report, the FO MUST advance the entity to the next stage and dispatch it (reuse-or-fresh per the dispatch module's reuse conditions) BEFORE ending its turn. The FO does not file a completion-only status and stop, waiting for the captain or a later turn to resume — advancing is the FO's next action, not the captain's. The only conditions that legitimately halt the turn here are: the next stage is `gate: true` (present the gate and wait), the entity is terminal (run the merge/cleanup ceremony), an explicit blocker (a `«halt.rebase-conflict»`, an unmet clarification), or a captain decision the contract requires. Absent one of those, stopping after a completion-only report is a contract violation. A blocker or captain decision halting the turn is rendered through `Skill(skill="spacedock:present-gate")` and its `## Decision Request` template — the FO's own recommendation, never the worker's options relayed as the option space.

**Advancing a completed worker (reuse-or-fresh)** — the reuse conditions, the reuse/fresh-dispatch procedures, and supersede-shutdown live in the deferred dispatch module, already loaded by the time a completion reaches this point. Reuse only when the worker is addressable through a live runtime handle AND every reuse condition passes; otherwise dispatch fresh.

Expand Down
83 changes: 83 additions & 0 deletions skills/integration/decision_request_live_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
//go:build live

// ABOUTME: Live drive of the present-gate decision-request rendering — runs a real first
// ABOUTME: officer over the halted-worker fixture and grades its final message.
package integration

import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)

func writeDecisionRequestFixture(t *testing.T) string {
t.Helper()
dir := t.TempDir()
for name, body := range map[string]string{
"README.md": decisionRequestWorkflow(),
"reading.md": decisionRequestEntity(),
} {
if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o644); err != nil {
t.Fatalf("write %s: %v", name, err)
}
}
for _, args := range [][]string{
{"init", "-q"},
{"-c", "user.name=Spacedock", "-c", "user.email=test@example.invalid", "add", "README.md", "reading.md"},
{"-c", "user.name=Spacedock", "-c", "user.email=test@example.invalid", "commit", "-qm", "fixture"},
} {
cmd := exec.Command("git", args...)
cmd.Dir = dir
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git %v: %v\n%s", args, err, out)
}
}
return dir
}

// TestLiveDecisionRequest is the only check here that can establish the template
// works; the offline table can only establish that its graders were not
// loosened. Grading runs through gradeDecisionRequest, the same entry point that
// table pins, so a grader relaxed to turn this green breaks a case there.
func TestLiveDecisionRequest(t *testing.T) {
bin := os.Getenv("SPACEDOCK_BIN")
if bin == "" {
t.Fatal("set SPACEDOCK_BIN to the current spacedock binary")
}
repo := os.Getenv("SPACEDOCK_REPO_ROOT")
if repo == "" {
out, err := exec.Command("git", "rev-parse", "--show-toplevel").Output()
if err != nil {
t.Fatalf("resolve repo root: %v", err)
}
repo = strings.TrimSpace(string(out))
}

fixture := writeDecisionRequestFixture(t)
finalPath := filepath.Join(fixture, "final.txt")

cmd := exec.Command(bin, "codex", "--plugin-dir", repo, "--skip-compat-check",
decisionRequestPrompt(fixture),
"--", "exec", "--json", "--dangerously-bypass-approvals-and-sandbox",
"--cd", fixture, "--output-last-message", finalPath)
cmd.Dir = fixture
stream, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("live run: %v\n%s", err, stream)
}

final, err := os.ReadFile(finalPath)
if err != nil {
t.Fatalf("read final message: %v", err)
}
if dir := os.Getenv("SPACEDOCK_LIVE_ARTIFACT_DIR"); dir != "" {
_ = os.WriteFile(filepath.Join(dir, "decision-request-final.txt"), final, 0o644)
_ = os.WriteFile(filepath.Join(dir, "decision-request-stream.jsonl"), stream, 0o644)
}

if failures := gradeDecisionRequest(string(final)); len(failures) > 0 {
t.Fatalf("decision request failed grading %v\n\n%s", failures, final)
}
}
Loading