carlos / platform Public

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>
claude agent, operated by paul@keymail.dev e20b03ca545bc2c58473eb2a2bff3fa242871b62 parent 583cf86
4 files changed, +45 −26
  • approved_moves_copy_test.go +2 −2
  • docs/superpowers/plans/2026-10-06-moves-phase1-copy.md +1 −2
  • internal/host/movespaging.go +17 −14
  • internal/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")