host: M-C20 goes to operators only, the stray page and the agent's log
moves-phase1-6 Tasks 4+5 fix round 1, ruling 1. M-C20 is operator copy: it is now the stray page's body (moveCfg.Page) and one line in the agent's log once the page is sent, and the app's members (Events) are told nothing. MV-N388, the page body it replaces, is withdrawn from the copy record. The stray subtest now asserts no member emission. No censused unit changes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
4 files changed,
+45
−26
approved_moves_copy_test.go+2 −2docs/superpowers/plans/2026-10-06-moves-phase1-copy.md+1 −2internal/host/movespaging.go+17 −14internal/host/movespaging_test.go+25 −8
diff --git a/approved_moves_copy_test.go b/approved_moves_copy_test.go| index 771a07634..db587b302 100644 |
| --- a/approved_moves_copy_test.go |
| +++ b/approved_moves_copy_test.go |
| @@ -83,7 +83,7 @@ var movesCopyIDs = []string{ |
| "MV-N365", "MV-N366", "MV-N367", "MV-N368", "MV-N369", "MV-N370", "MV-N371", |
| "MV-N372", "MV-N373", "MV-N374", "MV-N375", "MV-N376", "MV-N377", "MV-N378", "MV-N379", |
| "MV-N380", "MV-N381", |
| - "MV-N385", "MV-N386", "MV-N387", "MV-N388", "MV-N389", "MV-N390", |
| + "MV-N385", "MV-N386", "MV-N387", "MV-N389", "MV-N390", |
| } |
| // copyRow is one row of the record. The File cell is the file the sentence is |
| @@ -789,7 +789,7 @@ var movesReturnedSentences = map[string]map[string]bool{ |
| // The moves evidence's anomaly page (Plan 5 Task 9a, MV-N336 and MV-N337). |
| "internal/host/movesrefusals.go": {"movesAnomalyPage": true}, |
| // The box's move pages (Plan 6 Task 5, MV-N385 to MV-N390) and M-C20, |
| - // the app's platform log line for a stray process. |
| + // the operators' sentence for a stray process (the stray page's body). |
| "internal/host/movespaging.go": {"rolledBackPage": true, "strayPage": true, "strayLine": true, "startRefusedPage": true}, |
| // The console's move pass pages (Plan 5 Task 7, MV-N325 to MV-N334). |
| "internal/console/movesrecovery.go": {"moveWaitingPage": true, "movePreparingPageText": true, "moveOrphanPage": true, "moveCloseOverduePage": true, "moveAbortOverduePage": true}, |
diff --git a/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md b/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md| index 4a7e57d81..fa112854c 100644 |
| --- a/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md |
| +++ b/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md |
| @@ -312,7 +312,7 @@ The anomaly page (Task 9a's quiet refusals, `internal/host/movesrefusals.go`): a |
| ## Plan 6 |
| -(completed by Plan 6 Task 7. Task 2 takes MV-N319 to MV-N324, and made the abort door speak M-C13 on a console without the host claim, so M-C13 is now approved verbatim. Task 2b made the delete door speak M-C19 through moveDeleteRefusal in internal/console/apiinstancemove.go, so its File cell moves there (preflight L6) and it is approved verbatim. Task 1a takes MV-N350 to MV-N371 and Task 1b MV-N372 to MV-N379, Task 3 MV-N380 (the member line for stuck, preflight L9) and MV-N381 (the name a member line gives the home region, which a box knows only as the record's empty region; Task 3 fix round 1, ruling 4). Task 3 fix round 1 (ruling 6, preflight L12) moved M-C1 and M-C5 to M-C9 into shared templates in internal/moves/operatorcopy.go, which both move.go and the member stream (internal/host/movesevents.go) call, so their File cells, and MV-N380's, name that file; their text is unchanged (Plan 5 owns MV-N325 to MV-N349) for `carlos move` (move.go, and its usage line in main.go), and speaks M-C1 to M-C12 and M-C22 verbatim from move.go, so those rows are approved. Task 1b speaks M-C18 verbatim from move.go and MV-N11 from the resolve door of a console without the host claim, so both are approved. Task 4 adds no sentence: the detail answer's timings reuse MV-N319 when their read fails. Task 5 takes MV-N385 to MV-N390, the box's pages (internal/host/movespaging.go, preflight L8), and speaks M-C20 verbatim from movespaging.go, on the app's platform log when the cut's stop evidence finds a stray process, so M-C20 is approved) |
| +(completed by Plan 6 Task 7. Task 2 takes MV-N319 to MV-N324, and made the abort door speak M-C13 on a console without the host claim, so M-C13 is now approved verbatim. Task 2b made the delete door speak M-C19 through moveDeleteRefusal in internal/console/apiinstancemove.go, so its File cell moves there (preflight L6) and it is approved verbatim. Task 1a takes MV-N350 to MV-N371 and Task 1b MV-N372 to MV-N379, Task 3 MV-N380 (the member line for stuck, preflight L9) and MV-N381 (the name a member line gives the home region, which a box knows only as the record's empty region; Task 3 fix round 1, ruling 4). Task 3 fix round 1 (ruling 6, preflight L12) moved M-C1 and M-C5 to M-C9 into shared templates in internal/moves/operatorcopy.go, which both move.go and the member stream (internal/host/movesevents.go) call, so their File cells, and MV-N380's, name that file; their text is unchanged (Plan 5 owns MV-N325 to MV-N349) for `carlos move` (move.go, and its usage line in main.go), and speaks M-C1 to M-C12 and M-C22 verbatim from move.go, so those rows are approved. Task 1b speaks M-C18 verbatim from move.go and MV-N11 from the resolve door of a console without the host claim, so both are approved. Task 4 adds no sentence: the detail answer's timings reuse MV-N319 when their read fails. Task 5 takes MV-N385 to MV-N390, the box's pages (internal/host/movespaging.go, preflight L8), and speaks M-C20 verbatim from movespaging.go when the cut's stop evidence finds a stray process, so M-C20 is approved. Its fix round 1 (ruling 1) keeps M-C20 to operators, as the stray page's body and the agent's log line, never the app's members, so MV-N388, the body it replaced, is withdrawn) |
| | id | file | text | status | |
| |---|---|---|---| |
| @@ -352,7 +352,6 @@ The anomaly page (Task 9a's quiet refusals, `internal/host/movesrefusals.go`): a |
| | MV-N385 | internal/host/movespaging.go | carlos move rolled back: <host> | pending | |
| | MV-N386 | internal/host/movespaging.go | The move <id> of <host> rolled back, and <host> is serving on <box> again. | pending | |
| | MV-N387 | internal/host/movespaging.go | carlos move stray process: <host> | pending | |
| -| MV-N388 | internal/host/movespaging.go | <box> is running a process for <host> that the move <id> cannot account for, so it cannot prove <host> stopped. The cut keeps checking until its deadline. | pending | |
| | MV-N389 | internal/host/movespaging.go | carlos move start refused: <host> | pending | |
| | MV-N390 | internal/host/movespaging.go | <box> refused to start <host> for the move <id>: what it holds of <host> is not the generation the host claim names. Nothing was started. | pending | |
diff --git a/internal/host/movespaging.go b/internal/host/movespaging.go| index 2eef5f67a..e47b0919c 100644 |
| --- a/internal/host/movespaging.go |
| +++ b/internal/host/movespaging.go |
| @@ -23,8 +23,10 @@ import ( |
| // - rolled_back, a warning, once per op, where this box closes a rollback |
| // (recoverRun.finalize) or a revert (revertRun.close); |
| // - a stop evidence refused for a process the move cannot account for |
| -// (StepStray), an incident, once per op, from the cut's stopping; the |
| -// app's platform log then says M-C20; |
| +// (StepStray), an incident, once per op, from the cut's stopping: the |
| +// page's body is M-C20, which the agent's log says too. M-C20 is |
| +// operator copy: the app's members are never told (fix round 1, |
| +// ruling 1); |
| // - a refused start check (moves.StartCheck), an incident, once per op, |
| // from the probe owners (movesProbeOwners.startCheck). |
| // |
| @@ -45,6 +47,10 @@ import ( |
| // rule that fires every pass can never flood. Unwired (nil Page), nothing |
| // pages and nothing is marked. |
| +// pagingLogf is the agent's log for the paging code (a variable so tests |
| +// can read it). |
| +var pagingLogf = log.Printf |
| + |
| // pagedStep is the journal step that records a rule's page. |
| const pagedStep = "paged:" |
| @@ -132,19 +138,17 @@ func strayRefused(rep moves.StoppedReport) bool { |
| } |
| // pageStray pages an incident once per op for a stop evidence refused by a |
| -// stray process (§13), and the first time it is sent the app's platform log |
| -// says M-C20. rec is the cut's record. |
| +// stray process (§13). The page's body is M-C20, and once it is sent the |
| +// agent's log says M-C20 as well, with the move. rec is the cut's record. |
| func (w *movesWorker) pageStray(ctx context.Context, rec provision.Record) { |
| if rec.Move == nil || rec.Move.ID == "" { |
| return |
| } |
| - subject, body := strayPage(rec.Host, rec.Move.ID, w.cfg.Box) |
| + subject, body := strayPage(rec.Host, w.cfg.Box) |
| if !w.pageOnce(ctx, rec.Host, rec.Move.ID, pageRuleStray, alerting.LevelIncident, subject, body) { |
| return |
| } |
| - if sink := w.cfg.Events; sink != nil { |
| - sink.Event(rec.Account, rec.App, rec.Host, "warn", strayLine(w.cfg.Box, rec.Host)) |
| - } |
| + pagingLogf("moves: %s: move %s: %s", rec.Host, rec.Move.ID, strayLine(w.cfg.Box, rec.Host)) |
| } |
| // pageStartRefused pages an incident once per op for a refused §5.1 start |
| @@ -170,15 +174,14 @@ func rolledBackPage(host, id, box string) (subject, body string) { |
| } |
| // strayPage is the stray process's page: the subject (MV-N387) and the |
| -// body (MV-N388). |
| -func strayPage(host, id, box string) (subject, body string) { |
| +// body, M-C20. |
| +func strayPage(host, box string) (subject, body string) { |
| subject = fmt.Sprintf("carlos move stray process: %s", host) |
| - body = fmt.Sprintf("%s is running a process for %s that the move %s cannot account for, so it cannot prove %s stopped. The cut keeps checking until its deadline.", |
| - box, host, id, host) |
| - return subject, body |
| + return subject, strayLine(box, host) |
| } |
| -// strayLine is M-C20, the app's platform log line for the stray process. |
| +// strayLine is M-C20, the operators' sentence for the stray process: the |
| +// stray page's body and the agent's log line. |
| func strayLine(box, host string) string { |
| return fmt.Sprintf("%s is running a process for %s that this move cannot account for. An operator has been paged.", box, host) |
| } |
diff --git a/internal/host/movespaging_test.go b/internal/host/movespaging_test.go| index 628d19d80..9730c53f0 100644 |
| --- a/internal/host/movespaging_test.go |
| +++ b/internal/host/movespaging_test.go |
| @@ -3,6 +3,7 @@ package host |
| import ( |
| "context" |
| "errors" |
| + "fmt" |
| "strings" |
| "testing" |
| "time" |
| @@ -22,6 +23,16 @@ func pagesWith(pages pagesSeen, prefix string) (n int, level string) { |
| return n, level |
| } |
| +// capturePagingLog records the paging code's agent log lines for the test. |
| +func capturePagingLog(t *testing.T) *[]string { |
| + t.Helper() |
| + var lines []string |
| + saved := pagingLogf |
| + pagingLogf = func(format string, args ...any) { lines = append(lines, fmt.Sprintf(format, args...)) } |
| + t.Cleanup(func() { pagingLogf = saved }) |
| + return &lines |
| +} |
| + |
| // pagedInJournal reports whether op's journal on dataDir records rule's page. |
| func pagedInJournal(t *testing.T, dataDir, host, op, rule string) bool { |
| t.Helper() |
| @@ -118,7 +129,8 @@ func TestPagingIsRateLimited(t *testing.T) { |
| } |
| }) |
| - t.Run("a stray process at the stop, once per op, with M-C20", func(t *testing.T) { |
| + t.Run("a stray process at the stop, once per op, with M-C20 to operators only", func(t *testing.T) { |
| + logged := capturePagingLog(t) |
| h := newCutHarness(t) |
| h.ready(t) |
| var pages pagesSeen |
| @@ -143,15 +155,20 @@ func TestPagingIsRateLimited(t *testing.T) { |
| if n, level := pagesWith(pages, "carlos move stray process: "); n != 1 || level != "incident" { |
| t.Fatalf("stray pages %d (%s), want one incident: %+v", n, level, pages) |
| } |
| - mc20 := "bab/app/" + p1Host + " warn ie-box is running a process for " + p1Host + " that this move cannot account for. An operator has been paged." |
| - said := 0 |
| - for _, e := range sink.events { |
| - if e == mc20 { |
| - said++ |
| + // Fix round 1, ruling 1: M-C20 is operator copy. It is the page's |
| + // body and the agent's log line, and the app's members are told |
| + // nothing. |
| + mc20 := "ie-box is running a process for " + p1Host + " that this move cannot account for. An operator has been paged." |
| + for _, p := range pages { |
| + if strings.HasPrefix(p.subject, "carlos move stray process: ") && p.body != mc20 { |
| + t.Fatalf("the stray page's body %q, want M-C20 %q", p.body, mc20) |
| } |
| } |
| - if said != 1 { |
| - t.Fatalf("M-C20 said %d times, want once: %q", said, sink.events) |
| + if len(sink.events) != 0 { |
| + t.Fatalf("the app's members were told %q; M-C20 is the operators'", sink.events) |
| + } |
| + if n := strings.Count(strings.Join(*logged, "\n"), mc20); n != 1 { |
| + t.Fatalf("the agent's log says M-C20 %d times, want once: %q", n, *logged) |
| } |
| if !pagedInJournal(t, h.cfg.DataDir, p1Host, p1ID, pageRuleStray) { |
| t.Fatal("the cut's journal does not record the stray page") |