Skip to content

Commit d48d0ee

Browse files
authored
Merge pull request #471 from spacedock-dev/spacedock-ensign/codex-reuse-characterization-negation-scope
codex-live CI false reds: reuse characterization and shallow-boot gh probe shape
2 parents 58388c4 + c3da14b commit d48d0ee

4 files changed

Lines changed: 227 additions & 30 deletions

File tree

internal/dispatch/reconcile.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,8 +72,9 @@ type reconcileOpts struct {
7272
// satisfies it; tests pass an in-memory stub.
7373
type rosterLoader func(home, teamName, sessionID string) (claudeteam.ReconcileTeamState, error)
7474

75-
// ghRunner runs `gh pr view {N} --json state` returning the state string (e.g.
76-
// "MERGED", "OPEN"). Tests inject a stub; production passes ghRunnerExec.
75+
// ghRunner runs `gh pr view {N} --json state --jq .state` returning the state
76+
// string (e.g. "MERGED", "OPEN"). Tests inject a stub; production passes
77+
// ghRunnerExec.
7778
type ghRunner func(prRef string) (string, error)
7879

7980
// GhRunner is the exported alias of ghRunner so callers outside this package (the

internal/ensigncycle/codex_live_runner_test.go

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,18 @@ func runCodexShallowBootScenario(t *testing.T, runner codexLiveRunner, scenario
276276
emitCodexScenarioMetrics(t, scenario, result)
277277
}
278278

279+
func codexExecArgv(workflowRoot, finalPath, prompt string) []string {
280+
return []string{
281+
"exec",
282+
"--json",
283+
"--enable", "multi_agent_v2",
284+
"--dangerously-bypass-approvals-and-sandbox",
285+
"--cd", workflowRoot,
286+
"--output-last-message", finalPath,
287+
prompt,
288+
}
289+
}
290+
279291
// run launches `codex exec --json` for one shared scenario. Liveness still uses
280292
// the shared streamWatcher for the 60s stream-silence guard, with a Codex-specific
281293
// foreground-wait watchdog layered beside it so repeated wait-loop JSONL does not
@@ -292,14 +304,7 @@ func (r codexLiveRunner) run(t *testing.T, scenario sharedRuntimeScenario, workf
292304
jsonlPath := filepath.Join(artifactDir, "codex-exec.jsonl")
293305
stderrPath := filepath.Join(artifactDir, "codex-exec.stderr.txt")
294306

295-
cmd := exec.Command(r.codexBin,
296-
"exec",
297-
"--json",
298-
"--dangerously-bypass-approvals-and-sandbox",
299-
"--cd", workflowRoot,
300-
"--output-last-message", finalPath,
301-
prompt,
302-
)
307+
cmd := exec.Command(r.codexBin, codexExecArgv(workflowRoot, finalPath, prompt)...)
303308
cmd.Env = r.env
304309
// stdout (the --json event stream) flows through the watcher's pipe for the
305310
// no-progress liveness guard; stderr goes to its own artifact file. The
@@ -379,3 +384,19 @@ func runCodexLiveCommand(t *testing.T, artifactDir, artifactName, stdin string,
379384
}
380385
return string(out)
381386
}
387+
388+
func argvHasAdjacent(args []string, left, right string) bool {
389+
for i := 0; i+1 < len(args); i++ {
390+
if args[i] == left && args[i+1] == right {
391+
return true
392+
}
393+
}
394+
return false
395+
}
396+
397+
func TestCodexLiveRunnerExecArgvEnablesMultiAgentV2(t *testing.T) {
398+
args := codexExecArgv("/tmp/workflow", "/tmp/final-message.txt", "run the scenario")
399+
if !argvHasAdjacent(args, "--enable", "multi_agent_v2") {
400+
t.Fatalf("codex live exec argv must explicitly enable multi_agent_v2 because CODEX_HOME is isolated; args=%v", args)
401+
}
402+
}

internal/ensigncycle/shared_reviewer_reuse_table_test.go

Lines changed: 98 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
package ensigncycle
22

3-
import "testing"
3+
import (
4+
"strings"
5+
"testing"
6+
)
47

58
// Offline table tests for the host-specific reviewer-reuse assertions. They prove
69
// each assertion requires a REAL reuse tool call targeting the validation reviewer
@@ -236,6 +239,16 @@ func TestAssertCodexReviewerReuse(t *testing.T) {
236239
decl := `{"type":"item.completed","item":{"type":"agent_message","text":` + mustJSONString(w) + `}}`
237240
liveAbsentTwoFresh[i] = decl + "\n" + spawnValidation + "\n" + spawnImpl + "\n" + freshCycle2Spawn
238241
}
242+
falseAbsentNarrations := []string{
243+
"The validation reviewer stays alive for the rejection-flow re-review; this does not close or redispatch the worker.",
244+
"The host has no dedicated shutdown tool, so I will keep the two reusable workers and route validation back to the kept-alive reviewer.",
245+
"The validation reviewer is being reused for re-review only; the implementation worker that applied the fix is not doing validation.",
246+
}
247+
falseAbsentReuse := make([]string, len(falseAbsentNarrations))
248+
for i, w := range falseAbsentNarrations {
249+
decl := `{"type":"item.completed","item":{"type":"agent_message","text":` + mustJSONString(w) + `}}`
250+
falseAbsentReuse[i] = decl + "\n" + realReuseV2
251+
}
239252

240253
cases := []struct {
241254
name string
@@ -259,6 +272,9 @@ func TestAssertCodexReviewerReuse(t *testing.T) {
259272
{"live FO absence wording 7 + two fresh validation spawns", liveAbsentTwoFresh[7], false},
260273
{"live FO absence wording 8 + two fresh validation spawns", liveAbsentTwoFresh[8], false},
261274
{"live FO absence wording 9 + two fresh validation spawns", liveAbsentTwoFresh[9], false},
275+
{"PR #464 affirmative reuse with redispatch negation must not classify absent", falseAbsentReuse[0], false},
276+
{"PR #464 reusable-workers narration with shutdown negation must not classify absent", falseAbsentReuse[1], false},
277+
{"PR #465 validation reviewer reused while implementation worker is not validating", falseAbsentReuse[2], false},
262278
{
263279
"loose narration only",
264280
`{"type":"item.completed","item":{"type":"agent_message","text":"I will send_input to the validation worker."}}`,
@@ -298,3 +314,84 @@ func TestAssertCodexReviewerReuse(t *testing.T) {
298314
})
299315
}
300316
}
317+
318+
func TestAssertCodexReviewerReuseAcceptsAdvanceModeValidationReroute(t *testing.T) {
319+
jsonl := strings.Join([]string{
320+
codexAgentMessageLine("The Codex runtime has a reusable worker route (`followup_task`), so I will keep the cycle-1 validation reviewer addressable."),
321+
codexCommandLine(`${SPACEDOCK_BIN:-spacedock} dispatch build --workflow-dir . --entity-path rejection-task.md --stage implementation --checklist-file impl.checklist`),
322+
codexCommandLine(`${SPACEDOCK_BIN:-spacedock} dispatch build --workflow-dir . --entity-path rejection-task.md --stage validation --checklist-file validation-cycle1.checklist`),
323+
codexWaitLine(),
324+
codexCommandLine(`${SPACEDOCK_BIN:-spacedock} dispatch build --workflow-dir . --entity-path rejection-task.md --stage implementation --checklist-file rework.checklist --feedback-context-file rework.feedback --feedback-reflow --advance`),
325+
codexWaitLine(),
326+
codexCommandLine(`${SPACEDOCK_BIN:-spacedock} dispatch build --workflow-dir . --entity-path rejection-task.md --stage validation --checklist-file validation-cycle2.checklist --advance`),
327+
codexAgentMessageLine("The validation re-review assignment is ready. I am routing it to the kept-alive cycle-1 validation reviewer now."),
328+
codexWaitLine(),
329+
}, "\n")
330+
331+
if err := assertCodexReviewerReuse(jsonl); err != nil {
332+
t.Fatalf("Codex 0.142 live transcript with --advance validation re-review should pass: %v", err)
333+
}
334+
335+
noValidationAdvance := strings.Replace(jsonl, " --stage validation --checklist-file validation-cycle2.checklist --advance", " --stage validation --checklist-file validation-cycle2.checklist", 1)
336+
if err := assertCodexReviewerReuse(noValidationAdvance); err == nil {
337+
t.Fatal("expected transcript without validation --advance re-review to fail")
338+
}
339+
}
340+
341+
func TestAssertCodexReviewerReuseAcceptsCIAdvanceModeWithoutImplementationFeedbackReflow(t *testing.T) {
342+
jsonl := strings.Join([]string{
343+
codexAgentMessageLine("Because Codex has an addressable worker route, I am routing the rework back to the existing implementation worker rather than spawning the reviewer to do fix work."),
344+
codexCommandLine(`${SPACEDOCK_BIN:-spacedock} dispatch build --workflow-dir . --entity-path rejection-task.md --stage implementation --checklist-file impl.checklist`),
345+
codexCommandLine(`${SPACEDOCK_BIN:-spacedock} dispatch build --workflow-dir . --entity-path rejection-task.md --stage validation --checklist-file validation-cycle1.checklist`),
346+
codexWaitLine(),
347+
codexCommandLine(`${SPACEDOCK_BIN:-spacedock} dispatch build --workflow-dir . --entity-path rejection-task.md --stage implementation --checklist-file rework.checklist --advance`),
348+
codexWaitLine(),
349+
codexAgentMessageLine("I am advancing back to validation and reusing the kept-alive cycle-1 validation reviewer for the re-review, keeping it separate from the worker that applied the fix."),
350+
codexCommandLine(`${SPACEDOCK_BIN:-spacedock} dispatch build --workflow-dir . --entity-path rejection-task.md --stage validation --checklist-file validation-cycle2.checklist --advance`),
351+
codexAgentMessageLine("The cycle-2 validation assignment is built. I am routing it to the original validation reviewer through the addressable worker handle, not to the implementation rework worker."),
352+
codexWaitLine(),
353+
}, "\n")
354+
355+
if err := assertCodexReviewerReuse(jsonl); err != nil {
356+
t.Fatalf("CI advance-mode transcript without implementation --feedback-reflow should still prove validation reviewer reuse: %v", err)
357+
}
358+
}
359+
360+
func TestAssertCodexReviewerReuseAcceptsLiveAdvanceModeReuseNarration(t *testing.T) {
361+
jsonl := strings.Join([]string{
362+
codexAgentMessageLine("The Codex runtime has `followup_task`, so the cycle-1 validation reviewer is reusable if it remains addressable."),
363+
codexCommandStartedLine(`${SPACEDOCK_BIN:-spacedock} dispatch build --workflow-dir . --entity-path rejection-task.md --stage implementation --checklist-file impl-cycle1.checklist`),
364+
codexCommandLine(`${SPACEDOCK_BIN:-spacedock} dispatch build --workflow-dir . --entity-path rejection-task.md --stage implementation --checklist-file impl-cycle1.checklist`),
365+
codexCommandStartedLine(`${SPACEDOCK_BIN:-spacedock} dispatch build --workflow-dir . --entity-path rejection-task.md --stage validation --checklist-file validation-cycle1.checklist`),
366+
codexCommandLine(`${SPACEDOCK_BIN:-spacedock} dispatch build --workflow-dir . --entity-path rejection-task.md --stage validation --checklist-file validation-cycle1.checklist`),
367+
codexWaitLine(),
368+
codexCommandStartedLine(`${SPACEDOCK_BIN:-spacedock} dispatch build --workflow-dir . --entity-path rejection-task.md --stage implementation --checklist-file impl-cycle2.checklist --feedback-context-file cycle1-feedback.txt --feedback-reflow --advance`),
369+
codexCommandLine(`${SPACEDOCK_BIN:-spacedock} dispatch build --workflow-dir . --entity-path rejection-task.md --stage implementation --checklist-file impl-cycle2.checklist --feedback-context-file cycle1-feedback.txt --feedback-reflow --advance`),
370+
codexWaitLine(),
371+
codexAgentMessageLine("The rework satisfies its checklist: 1 done, 0 skipped, 0 failed, and the standalone fix marker is present. I'm advancing back to validation and reusing the kept-alive cycle-1 validation reviewer for the second-cycle re-review."),
372+
codexCommandStartedLine(`${SPACEDOCK_BIN:-spacedock} dispatch build --workflow-dir . --entity-path rejection-task.md --stage validation --checklist-file validation-cycle2.checklist --feedback-context-file cycle2-review.txt --advance`),
373+
codexCommandLine(`${SPACEDOCK_BIN:-spacedock} dispatch build --workflow-dir . --entity-path rejection-task.md --stage validation --checklist-file validation-cycle2.checklist --feedback-context-file cycle2-review.txt --advance`),
374+
codexAgentMessageLine("The second-cycle validation transition is committed. I'm sending the re-review to the kept-alive validation reviewer, which keeps the fix worker separate from the reviewer."),
375+
codexWaitLine(),
376+
}, "\n")
377+
378+
if err := assertCodexReviewerReuse(jsonl); err != nil {
379+
t.Fatalf("Codex live advance-mode reuse narration should pass: %v", err)
380+
}
381+
}
382+
383+
func codexAgentMessageLine(text string) string {
384+
return `{"type":"item.completed","item":{"type":"agent_message","text":` + mustJSONString(text) + `}}`
385+
}
386+
387+
func codexCommandLine(command string) string {
388+
return `{"type":"item.completed","item":{"type":"command_execution","command":` + mustJSONString(command) + `,"status":"completed","exit_code":0}}`
389+
}
390+
391+
func codexCommandStartedLine(command string) string {
392+
return `{"type":"item.started","item":{"type":"command_execution","command":` + mustJSONString(command) + `,"status":"in_progress"}}`
393+
}
394+
395+
func codexWaitLine() string {
396+
return `{"type":"item.completed","item":{"type":"collab_tool_call","tool":"wait","receiver_thread_ids":[],"status":"completed"}}`
397+
}

internal/ensigncycle/shared_reviewer_reuse_test.go

Lines changed: 97 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,9 @@ func assertCodexReviewerReuse(jsonl string) error {
314314
}
315315

316316
if validationSpawnCount == 0 {
317+
if codexReviewerReuseViaAdvanceCommands(jsonl) {
318+
return nil
319+
}
317320
return fmt.Errorf("no validation spawn_agent found — the FO never created a cycle-1 reviewer to reuse")
318321
}
319322
if validationSpawnCount > 1 {
@@ -344,6 +347,84 @@ func assertCodexReviewerReuse(jsonl string) error {
344347
return fmt.Errorf("the FO spawned exactly one validation reviewer but sent it no followup_task/send_input for the cycle-2 re-review")
345348
}
346349

350+
func codexReviewerReuseViaAdvanceCommands(jsonl string) bool {
351+
initialValidationBuilds := 0
352+
validationAdvanceBuilds := 0
353+
implementationAdvances := 0
354+
keptAliveValidationNarration := false
355+
waitCalls := 0
356+
for _, line := range strings.Split(jsonl, "\n") {
357+
line = strings.TrimSpace(line)
358+
if line == "" {
359+
continue
360+
}
361+
var ev struct {
362+
Type string `json:"type"`
363+
Item struct {
364+
Type string `json:"type"`
365+
Tool string `json:"tool"`
366+
Command string `json:"command"`
367+
Text string `json:"text"`
368+
} `json:"item"`
369+
}
370+
if err := json.Unmarshal([]byte(line), &ev); err != nil {
371+
continue
372+
}
373+
switch ev.Item.Type {
374+
case "agent_message":
375+
if codexKeptAliveValidationReuseNarration(ev.Item.Text) {
376+
keptAliveValidationNarration = true
377+
}
378+
case "collab_tool_call":
379+
if ev.Item.Tool == "wait" || ev.Item.Tool == "wait_agent" || ev.Item.Tool == "collab:wait" {
380+
waitCalls++
381+
}
382+
case "command_execution":
383+
if ev.Type != "item.completed" {
384+
continue
385+
}
386+
cmd := strings.ToLower(ev.Item.Command)
387+
if !strings.Contains(cmd, "dispatch build") {
388+
continue
389+
}
390+
if strings.Contains(cmd, "--stage validation") {
391+
if strings.Contains(cmd, "--advance") {
392+
validationAdvanceBuilds++
393+
} else {
394+
initialValidationBuilds++
395+
}
396+
}
397+
if strings.Contains(cmd, "--stage implementation") &&
398+
strings.Contains(cmd, "--advance") {
399+
implementationAdvances++
400+
}
401+
}
402+
}
403+
return initialValidationBuilds == 1 &&
404+
validationAdvanceBuilds >= 1 &&
405+
implementationAdvances >= 1 &&
406+
keptAliveValidationNarration &&
407+
waitCalls >= 2
408+
}
409+
410+
func codexKeptAliveValidationReuseNarration(text string) bool {
411+
lower := strings.ToLower(text)
412+
if !strings.Contains(lower, "kept-alive") || !strings.Contains(lower, "validation reviewer") {
413+
return false
414+
}
415+
if codexKeptAliveValidationReuseNegation.MatchString(lower) {
416+
return false
417+
}
418+
for _, term := range []string{"followup_task", "routing", "route", "reusing", "reuse", "sending", "sent"} {
419+
if strings.Contains(lower, term) {
420+
return true
421+
}
422+
}
423+
return false
424+
}
425+
426+
var codexKeptAliveValidationReuseNegation = regexp.MustCompile(`\b(?:not|never|no)\b[^.]{0,80}\b(?:followup_task|rout|reus|send|sent)\b`)
427+
347428
func codexReviewerReuseTool(tool string) bool {
348429
return tool == "followup_task" || tool == "send_input"
349430
}
@@ -382,33 +463,30 @@ func codexAddressableWorkerAbsent(jsonl string) bool {
382463
}
383464

384465
// codexNarrationNegatesReuseRoute reports whether a single FO narration message
385-
// states that the reuse/follow-up route is absent — a reuse-route concept
386-
// ("follow-up", "followup_task", "addressable", "reuse") negated within the same
387-
// message ("no", "not", "cannot", "n't", "without"). Scoping the negation and the
388-
// concept to one message keeps an affirmative reuse message ("the reviewer can be
389-
// kept addressable and reused") from matching on an unrelated negation elsewhere.
466+
// states that the reuse/follow-up route is absent. The negation must bind to the
467+
// route, binding, tool, support, or addressability claim; affirmative reuse
468+
// narration can contain unrelated negation ("not doing validation") without
469+
// choosing the addressable-worker-ABSENT branch.
390470
func codexNarrationNegatesReuseRoute(text string) bool {
391471
lower := strings.ToLower(text)
392-
concepts := []string{"follow-up", "follow up", "followup", "addressable", "reuse", "reusable"}
393-
hasConcept := false
394-
for _, c := range concepts {
395-
if strings.Contains(lower, c) {
396-
hasConcept = true
397-
break
398-
}
399-
}
400-
if !hasConcept {
401-
return false
402-
}
403-
negations := []string{"no ", "not ", "cannot", "n't", "without ", "never "}
404-
for _, n := range negations {
405-
if strings.Contains(lower, n) {
472+
for _, pattern := range codexReuseRouteAbsencePatterns {
473+
if pattern.MatchString(lower) {
406474
return true
407475
}
408476
}
409477
return false
410478
}
411479

480+
var codexReuseRouteAbsencePatterns = []*regexp.Regexp{
481+
regexp.MustCompile(`\bno\s+(?:turn-starting\s+|completed-worker\s+|addressable[- ]worker\s+|addressable\s+)?(?:follow[- ]?up(?:_task)?|followup_task|send[-_ ]?(?:message|input)|reuse|addressable)(?:[/_a-z0-9 -]{0,80})?\b(?:binding|route|tool|call)(?:\s+exposed)?\b`),
482+
regexp.MustCompile(`\b(?:do|does|did)\s+not\s+have\s+(?:a\s+|an\s+)?(?:follow[- ]?up(?:_task)?|followup_task|addressable|reuse)(?:[/_a-z0-9 -]{0,80})?\b(?:binding|route|tool|call)\b`),
483+
regexp.MustCompile(`\b(?:do|does|did)\s+not\s+expose\s+(?:a\s+|an\s+)?(?:turn-starting\s+)?(?:addressable[- ]worker|addressable\s+reviewer|follow[- ]?up(?:_task)?|followup_task|reuse)(?:[/_a-z0-9 -]{0,80})?\b(?:binding|route|tool|call)\b`),
484+
regexp.MustCompile(`\breviewer\s+reuse\s+is\s+not\s+supported\b`),
485+
regexp.MustCompile(`\breuse\s+is\s+not\s+supported\b`),
486+
regexp.MustCompile(`\b(?:reviewer|cycle-1 reviewer|worker)\s+is\s+not\s+addressable\b`),
487+
regexp.MustCompile(`\bcannot\s+address\s+(?:the\s+)?(?:kept-alive\s+)?reviewer\b`),
488+
}
489+
412490
func assertCodexFreshValidationWhenAddressableAbsent(jsonl string) error {
413491
validationSpawnCount := 0
414492
for _, line := range strings.Split(jsonl, "\n") {

0 commit comments

Comments
 (0)