host: paging for moves through the alert sinks, rate-limited per op (§13)
moves-phase1-6 Task 5 (preflight L8). The box pages through moveCfg.Page only what it alone sees, each rule once, its state the op journal's paged:<rule> step, so a restarted agent does not page it again: - stuck, an incident, once per entry into stuck (stuck-<retry epoch>); pageStuck moves to movespaging.go and is the one stuck page; - rolled_back, a warning, where this box closes a rollback or a revert; - a stop evidence refused for a stray process, an incident from the cut's stopping, and M-C20 on the app's platform log (approved verbatim); - a refused start check, an incident from the probe owners (a Page field, wired when claim Plan 10 registers the owner set). The console's move pass keeps release-deadline (hourly), preparing (once, from moves.PreparingSince), orphan, close-overdue and abort-overdue; M-C22 stays Plan 5 Task 7a's. Copy: MV-N385 to MV-N390 pending; M-C20 approved. Census: recoverRun.finalize's B8 digest (one page call after the close).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
11 files changed,
+445
−33
approved_moves_copy_test.go+5 −0docs/superpowers/plans/2026-10-06-moves-phase1-copy.md+8 −2internal/host/movescut.go+3 −0internal/host/movespaging.go+193 −0internal/host/movespaging_test.go+215 −0internal/host/movespass.go+5 −3internal/host/movesprobeowners.go+11 −1internal/host/movesrecover.go+1 −0internal/host/movesrevert.go+1 −0internal/host/movesrollback.go+1 −25internal/provision/plan9_b8_census_test.go+2 −2
diff --git a/approved_moves_copy_test.go b/approved_moves_copy_test.go| index d28d31ca9..771a07634 100644 |
| --- a/approved_moves_copy_test.go |
| +++ b/approved_moves_copy_test.go |
| @@ -83,6 +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", |
| } |
| // copyRow is one row of the record. The File cell is the file the sentence is |
| @@ -616,6 +617,7 @@ var movesScanFiles = []string{ |
| "internal/host/movesmeasure_linux.go", |
| "internal/host/movesmeasure_other.go", |
| "internal/host/movesparks.go", |
| + "internal/host/movespaging.go", |
| "internal/host/movespass.go", |
| "internal/host/movesphase1.go", |
| "internal/host/movespolicy.go", |
| @@ -786,6 +788,9 @@ var movesReturnedSentences = map[string]map[string]bool{ |
| "internal/console/apiinstancemove.go": {"moveDeleteRefusal": true}, |
| // 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. |
| + "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 9c0cfd279..4a7e57d81 100644 |
| --- a/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md |
| +++ b/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md |
| @@ -32,7 +32,7 @@ Columns: id, file, text, status. |
| | M-C17 | internal/console/apiinstancemove.go | <host> uses private storage. Instances with private storage cannot move yet. | approved | |
| | M-C18 | move.go | This declares <box> lost. Anything <host> wrote on <box> after <time> is lost. Type the host name to confirm. | approved | |
| | M-C19 | internal/console/apiinstancemove.go | <host> is being moved. Delete it after the move finishes, or abort the move first. | approved | |
| -| M-C20 | internal/host/movespaging.go | <box> is running a process for <host> that this move cannot account for. An operator has been paged. | pending | |
| +| M-C20 | internal/host/movespaging.go | <box> is running a process for <host> that this move cannot account for. An operator has been paged. | approved | |
| | M-C21 | internal/console/apiinstancemove.go | <region> has more than one box. Moves between regions with several boxes are not supported yet. | approved | |
| | M-C22 | move.go | <host> is waiting for a box in <region>. Its last box was terminated before the move finished. Its data up to the last completed upload is kept in <region>. It will start when a box joins the region, and any writes after that upload will be reported then. | approved | |
| @@ -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) |
| +(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) |
| | id | file | text | status | |
| |---|---|---|---| |
| @@ -349,6 +349,12 @@ The anomaly page (Task 9a's quiet refusals, `internal/host/movesrefusals.go`): a |
| | MV-N379 | move.go | The move changed since it was read. Nothing was declared. Run the command again. | pending | |
| | MV-N380 | internal/moves/operatorcopy.go | <host> could not start in <to> or in <from>. It is offline. An operator has been paged. | pending | |
| | MV-N381 | internal/moves/operatorcopy.go | the home region | pending | |
| +| 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 | |
| ## Review of 2026-10-09 |
diff --git a/internal/host/movescut.go b/internal/host/movescut.go| index daeceb755..db322735c 100644 |
| --- a/internal/host/movescut.go |
| +++ b/internal/host/movescut.go |
| @@ -422,6 +422,9 @@ func (c *cutRun) stopping(ctx context.Context) (bool, error) { |
| } |
| if !rep.TreeStopped() { |
| c.w.notice(host+"#evidence", "moves: %s: the instance is not proven stopped yet (%s); retrying until the cut's deadline", host, failedSteps(rep)) |
| + if strayRefused(rep) { |
| + c.w.pageStray(ctx, c.rec) |
| + } |
| return false, nil |
| } |
| j, err := c.journal() |
diff --git a/internal/host/movespaging.go b/internal/host/movespaging.go| new file mode 100644 |
| index 000000000..2eef5f67a |
| --- /dev/null |
| +++ b/internal/host/movespaging.go |
| @@ -0,0 +1,193 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "fmt" |
| + "log" |
| + "strconv" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/alerting" |
| + "github.com/carlosframework/platform/internal/moves" |
| + "github.com/carlosframework/platform/internal/provision" |
| +) |
| + |
| +// Paging for moves from the box (spec §13; Plan 6 Task 5; preflight L8). The |
| +// box pages, through moveCfg.Page (PageOperator, the agent's alert sinks; |
| +// Plan 5 Task 0), only what it alone sees: |
| +// |
| +// - stuck, an incident, once per entry into stuck (pageStuck): the rule is |
| +// stuck-<epoch>, the record's recovery retry epoch, which only an |
| +// operator's retry advances, so a retried move that sticks again pages |
| +// again and a replay of the same mark does not; |
| +// - 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; |
| +// - a refused start check (moves.StartCheck), an incident, once per op, |
| +// from the probe owners (movesProbeOwners.startCheck). |
| +// |
| +// The console's move pass (internal/console/movesrecovery.go, Plan 5 Task 7) |
| +// keeps the rules whose facts it holds, and the box does not repeat them: |
| +// rolling_back past release_deadline (an incident, once an hour), preparing |
| +// with no promotable round for 60 minutes (a warning, once, measured from |
| +// moves.PreparingSince, the six-hour abort's own start), an orphaned claim, |
| +// an overdue close and an overdue abort. A move converted to relocating with |
| +// no live box in its region (M-C22) is Plan 5 Task 7a's page. |
| +// |
| +// A rule's rate state is the op journal on this box (moves.Journal, step |
| +// paged:<rule>, the time it was marked), so a restarted agent does not page |
| +// it again. The step is written before the page is sent: a crash between the |
| +// two, or a send that fails (logged), pages nothing more, as the console's |
| +// move_pages records an attempt whether or not delivery worked (pages are |
| +// best effort). A journal that cannot be read or written sends nothing, so a |
| +// rule that fires every pass can never flood. Unwired (nil Page), nothing |
| +// pages and nothing is marked. |
| + |
| +// pagedStep is the journal step that records a rule's page. |
| +const pagedStep = "paged:" |
| + |
| +// The rules' names in the journal. |
| +const ( |
| + pageRuleRolledBack = "rolled_back" |
| + pageRuleStray = "stray" |
| + pageRuleStartCheck = "start_check" |
| +) |
| + |
| +// pageRuleStuck is the stuck rule of one entry into stuck: its retry epoch. |
| +func pageRuleStuck(m *provision.MoveState) string { |
| + return "stuck-" + strconv.FormatUint(m.RecoveryRetryEpoch, 10) |
| +} |
| + |
| +// pageOnce sends one page for rule unless the journal of (host, op) under |
| +// dataDir says it was sent: it marks the journal, then pages. sent is true |
| +// only when the page was handed to page without error. |
| +func pageOnce(ctx context.Context, page func(ctx context.Context, level, subject, body string) error, dataDir, host, op, rule string, now time.Time, level, subject, body string) (sent bool) { |
| + if page == nil { |
| + return false |
| + } |
| + j, err := moves.ReadJournal(dataDir, host, op) |
| + if err != nil { |
| + log.Printf("moves: %s: the %s page of %s is not sent: its journal: %v", host, rule, op, err) |
| + return false |
| + } |
| + if j.Done(pagedStep + rule) { |
| + return false |
| + } |
| + j.Mark(pagedStep+rule, now.UTC().Format(time.RFC3339)) |
| + if err := j.Write(dataDir); err != nil { |
| + log.Printf("moves: %s: the %s page of %s is not sent: its journal: %v", host, rule, op, err) |
| + return false |
| + } |
| + if err := page(ctx, level, subject, body); err != nil { |
| + log.Printf("moves: %s: the %s page of %s: %v", host, rule, op, err) |
| + return false |
| + } |
| + return true |
| +} |
| + |
| +// pageOnce is the worker's pageOnce: its pager, journals and clock. |
| +func (w *movesWorker) pageOnce(ctx context.Context, host, op, rule, level, subject, body string) bool { |
| + return pageOnce(ctx, w.cfg.Page, w.cfg.DataDir, host, op, rule, w.now(), level, subject, body) |
| +} |
| + |
| +// pageStuck pages an operator once a MoveMarkStuck has landed (moves spec |
| +// §13: stuck is an incident; K11), once per entry into stuck. role is this |
| +// box's part in the move, "source" or "target", and reason the recorded |
| +// stuck sentence. The page names the host, the move, the box and the |
| +// reason, and nothing else: no path, no error text, no secret. Its journal |
| +// is the one the stopped half keeps: a rollback's source keeps its |
| +// recovery's (the rollback id), every other half the move id's. |
| +func (w *movesWorker) pageStuck(ctx context.Context, rec provision.Record, role, reason string) { |
| + m := rec.Move |
| + if m == nil || m.ID == "" { |
| + return |
| + } |
| + op := m.ID |
| + if role == "source" && m.State == provision.MoveRollingBack && m.Rollback != nil && m.Rollback.ID != "" { |
| + op = m.Rollback.ID |
| + } |
| + subject, body := stuckPage(rec.Host, m.ID, w.cfg.Box, role, reason) |
| + w.pageOnce(ctx, rec.Host, op, pageRuleStuck(m), alerting.LevelIncident, subject, body) |
| +} |
| + |
| +// pageRolledBack pages a warning once this box has closed a move rolled |
| +// back (§13): rec is the record as the close found it, op the journal the |
| +// closing half keeps (the recovery's rollback id, or the revert's move id). |
| +func (w *movesWorker) pageRolledBack(ctx context.Context, rec provision.Record, op string) { |
| + if rec.Move == nil || rec.Move.ID == "" { |
| + return |
| + } |
| + subject, body := rolledBackPage(rec.Host, rec.Move.ID, w.cfg.Box) |
| + w.pageOnce(ctx, rec.Host, op, pageRuleRolledBack, alerting.LevelWarning, subject, body) |
| +} |
| + |
| +// strayRefused reports whether a stop evidence report was refused for a |
| +// process the move cannot account for: its StepStray ran and failed. A step |
| +// that never ran is not a refusal. |
| +func strayRefused(rep moves.StoppedReport) bool { |
| + c, ok := rep.Steps[moves.StepStray] |
| + return ok && !c.OK |
| +} |
| + |
| +// 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. |
| +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) |
| + 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)) |
| + } |
| +} |
| + |
| +// pageStartRefused pages an incident once per op for a refused §5.1 start |
| +// check on this box (§13). The probe owners' lock is held across the |
| +// journal's read and write, as their other journal writes hold it. |
| +func (m *movesProbeOwners) pageStartRefused(ctx context.Context, host, op string) { |
| + now := time.Now() |
| + if m.Now != nil { |
| + now = m.Now() |
| + } |
| + subject, body := startRefusedPage(host, op, m.Box) |
| + m.mu.Lock() |
| + defer m.mu.Unlock() |
| + pageOnce(ctx, m.Page, m.DataDir, host, op, pageRuleStartCheck, now, alerting.LevelIncident, subject, body) |
| +} |
| + |
| +// rolledBackPage is the rolled_back page: the subject (MV-N385) and the |
| +// body (MV-N386). |
| +func rolledBackPage(host, id, box string) (subject, body string) { |
| + subject = fmt.Sprintf("carlos move rolled back: %s", host) |
| + body = fmt.Sprintf("The move %s of %s rolled back, and %s is serving on %s again.", id, host, host, box) |
| + return subject, body |
| +} |
| + |
| +// 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) { |
| + 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 |
| +} |
| + |
| +// strayLine is M-C20, the app's platform log line for the stray process. |
| +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) |
| +} |
| + |
| +// startRefusedPage is the refused start check's page: the subject |
| +// (MV-N389) and the body (MV-N390). |
| +func startRefusedPage(host, id, box string) (subject, body string) { |
| + subject = fmt.Sprintf("carlos move start refused: %s", host) |
| + body = fmt.Sprintf("%s refused to start %s for the move %s: what it holds of %s is not the generation the host claim names. Nothing was started.", |
| + box, host, id, host) |
| + return subject, body |
| +} |
diff --git a/internal/host/movespaging_test.go b/internal/host/movespaging_test.go| new file mode 100644 |
| index 000000000..628d19d80 |
| --- /dev/null |
| +++ b/internal/host/movespaging_test.go |
| @@ -0,0 +1,215 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/moves" |
| + "github.com/carlosframework/platform/internal/provision" |
| +) |
| + |
| +// pagesWith counts the pages whose subject starts with prefix. |
| +func pagesWith(pages pagesSeen, prefix string) (n int, level string) { |
| + for _, p := range pages { |
| + if strings.HasPrefix(p.subject, prefix) { |
| + n++ |
| + level = p.level |
| + } |
| + } |
| + return n, level |
| +} |
| + |
| +// 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() |
| + j, err := moves.ReadJournal(dataDir, host, op) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return j.Done(pagedStep + rule) |
| +} |
| + |
| +// TestPagingIsRateLimited (Review Focus 4; spec §13; preflight L8): a rule |
| +// that fires every pass must not flood. On the box, stuck pages once per |
| +// entry into stuck (a retry that sticks again pages again), rolled_back once |
| +// per op, a stray process at the stop once per op, and a refused start check |
| +// once per op; each rule's state is the op journal, so a restarted agent |
| +// does not page it again. The console's own rules are tested where they |
| +// live: rolling_back past release_deadline once an hour |
| +// (TestConsolePagesThroughAlerting), preparing once |
| +// (TestRecoveryPassAbortsAfterSixHours) from moves.PreparingSince |
| +// (TestPreparingPageRunsFromPreparingSince). |
| +func TestPagingIsRateLimited(t *testing.T) { |
| + ctx := context.Background() |
| + |
| + t.Run("stuck, once per entry", func(t *testing.T) { |
| + dd := t.TempDir() |
| + var pages pagesSeen |
| + worker := func() *movesWorker { |
| + return &movesWorker{cfg: moveCfg{DataDir: dd, Box: "uk-box", Page: pages.hook(), Now: func() time.Time { return time.Date(2026, 10, 11, 9, 0, 0, 0, time.UTC) }}} |
| + } |
| + rec := provision.Record{Host: p1Host, Move: &provision.MoveState{ID: p1ID, State: provision.MoveRollingBack, |
| + Rollback: &provision.MoveRollback{ID: "rb_abcdefghijklmnopqrstuvwxyz", TargetPhase: provision.RbTargetReturning}}} |
| + w := worker() |
| + for i := 0; i < 3; i++ { |
| + w.pageStuck(ctx, rec, "target", provision.StuckReturnTargetDatabase) |
| + } |
| + worker().pageStuck(ctx, rec, "target", provision.StuckReturnTargetDatabase) // a restarted agent |
| + if n, level := pagesWith(pages, "carlos move stuck: "); n != 1 || level != "incident" { |
| + t.Fatalf("stuck pages %d (%s), want one incident: %+v", n, level, pages) |
| + } |
| + if !pagedInJournal(t, dd, p1Host, p1ID, "stuck-0") { |
| + t.Fatal("the target's journal of the move does not record the stuck page") |
| + } |
| + // An operator's retry advances the epoch; stuck again is a new entry. |
| + rec.Move.RecoveryRetryEpoch = 1 |
| + w.pageStuck(ctx, rec, "target", provision.StuckReturnTargetDatabase) |
| + w.pageStuck(ctx, rec, "target", provision.StuckReturnTargetDatabase) |
| + if n, _ := pagesWith(pages, "carlos move stuck: "); n != 2 { |
| + t.Fatalf("stuck pages after a retry %d, want two: %+v", n, pages) |
| + } |
| + // The source of a rollback keeps its recovery's journal. |
| + w.pageStuck(ctx, rec, "source", provision.StuckBaselineMismatch) |
| + w.pageStuck(ctx, rec, "source", provision.StuckBaselineMismatch) |
| + if n, _ := pagesWith(pages, "carlos move stuck: "); n != 3 || !pagedInJournal(t, dd, p1Host, "rb_abcdefghijklmnopqrstuvwxyz", "stuck-1") { |
| + t.Fatalf("the source's stuck page %d, want one more, in the recovery's journal: %+v", n, pages) |
| + } |
| + }) |
| + |
| + t.Run("rolled_back, once per op, at the recovery's close", func(t *testing.T) { |
| + h := newRecoverHarness(t, false) |
| + h.released(t) |
| + h.srcPass(t) |
| + h.opened(t) |
| + h.closeOut(t) |
| + if rec := h.read(t, p1Host); rec.Move != nil || rec.LastMove == nil || rec.LastMove.Outcome != "rolled_back" { |
| + t.Fatalf("not closed: %+v %+v", rec.Move, rec.LastMove) |
| + } |
| + h.srcPass(t) |
| + h.sw = nil // a restarted agent |
| + h.srcPass(t) |
| + h.rollbackPass(t) |
| + n, level := pagesWith(h.pages, "carlos move rolled back: ") |
| + if n != 1 || level != "warning" { |
| + t.Fatalf("rolled_back pages %d (%s), want one warning: %+v", n, level, h.pages) |
| + } |
| + want := "The move " + p1ID + " of " + p1Host + " rolled back, and " + p1Host + " is serving on ie-box again." |
| + for _, p := range h.pages { |
| + if strings.HasPrefix(p.subject, "carlos move rolled back: ") && p.body != want { |
| + t.Fatalf("the page's body %q, want %q", p.body, want) |
| + } |
| + } |
| + }) |
| + |
| + t.Run("rolled_back, once per op, at the revert's close", func(t *testing.T) { |
| + h := newRevertHarness(t) |
| + h.fromStopped(t) |
| + h.arm(t) |
| + var pages pagesSeen |
| + h.cfg.Page = pages.hook() |
| + h.drive(t) |
| + h.closed(t) |
| + h.pass(t) |
| + if n, level := pagesWith(pages, "carlos move rolled back: "); n != 1 || level != "warning" { |
| + t.Fatalf("rolled_back pages %d (%s), want one warning: %+v", n, level, pages) |
| + } |
| + }) |
| + |
| + t.Run("a stray process at the stop, once per op, with M-C20", func(t *testing.T) { |
| + h := newCutHarness(t) |
| + h.ready(t) |
| + var pages pagesSeen |
| + sink := &memberSink{} |
| + h.cfg.Page, h.cfg.Events = pages.hook(), sink |
| + evidence := h.cfg.Evidence |
| + h.cfg.Evidence = func(ctx context.Context, host, location string, dbs []string) (moves.StoppedReport, error) { |
| + r, err := evidence(ctx, host, location, dbs) |
| + r.Steps[moves.StepStray] = moves.Check{Reason: "1 process(es) besides the agent in its leaf"} |
| + r.Stray = []int{4242} |
| + return r, err |
| + } |
| + h.cut(t) |
| + for i := 0; i < 3; i++ { |
| + h.pass(t) |
| + } |
| + h.freshWorker() // a restarted agent |
| + h.pass(t) |
| + if m := h.move(t); m.CutPhase != provision.CutStopping || h.ev.calls < 4 { |
| + t.Fatalf("the stray process did not hold the cut at stopping: %+v after %d evidence calls", m, h.ev.calls) |
| + } |
| + 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++ |
| + } |
| + } |
| + if said != 1 { |
| + t.Fatalf("M-C20 said %d times, want once: %q", said, sink.events) |
| + } |
| + if !pagedInJournal(t, h.cfg.DataDir, p1Host, p1ID, pageRuleStray) { |
| + t.Fatal("the cut's journal does not record the stray page") |
| + } |
| + // Evidence that fails for any other reason pages nothing. |
| + h2 := newCutHarness(t) |
| + h2.ready(t) |
| + var pages2 pagesSeen |
| + h2.cfg.Page = pages2.hook() |
| + h2.ev.stopped = false // the database is still held open: StepFDs |
| + h2.cut(t) |
| + h2.pass(t) |
| + h2.pass(t) |
| + if len(pages2) != 0 { |
| + t.Fatalf("an unproven stop with no stray process paged: %+v", pages2) |
| + } |
| + }) |
| + |
| + t.Run("a refused start check, once per op", func(t *testing.T) { |
| + f := newPOFixture(t) |
| + var pages pagesSeen |
| + f.owners.Page = pages.hook() |
| + o := f.read() |
| + in, err := f.owners.Select(ctx, f.route, o, poProbe) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + admitted := f.admit(o, in) |
| + spawn := poWork{action: "spawn", f: f} |
| + f.writeLocal("mv_other", poOp, f.inc) |
| + for i := 0; i < 3; i++ { |
| + if err := f.owners.Materialize(ctx, admitted, in, spawn); !errors.Is(err, moves.ErrStartFiles) { |
| + t.Fatalf("files of another op: %v", err) |
| + } |
| + } |
| + // A restarted agent: the owners' memory is gone, the journal is not. |
| + f.owners.mu.Lock() |
| + f.owners.published, f.owners.fences = nil, nil |
| + f.owners.mu.Unlock() |
| + f.writeLocal(poOp, "src", f.inc) |
| + if err := f.owners.Materialize(ctx, admitted, in, spawn); err == nil { |
| + t.Fatal("a database of another generation started") |
| + } |
| + if n, level := pagesWith(pages, "carlos move start refused: "); n != 1 || level != "incident" { |
| + t.Fatalf("start check pages %d (%s), want one incident: %+v", n, level, pages) |
| + } |
| + if !pagedInJournal(t, f.dataDir, poHost, poOp, pageRuleStartCheck) { |
| + t.Fatal("the landing's journal does not record the start check page") |
| + } |
| + }) |
| + |
| + t.Run("a journal that cannot be written sends nothing", func(t *testing.T) { |
| + var pages pagesSeen |
| + if pageOnce(ctx, pages.hook(), t.TempDir(), "bad/host", p1ID, pageRuleStray, time.Now(), "incident", "s", "b") || len(pages) != 0 { |
| + t.Fatalf("paged without a journal: %+v", pages) |
| + } |
| + if pageOnce(ctx, nil, t.TempDir(), p1Host, p1ID, pageRuleStray, time.Now(), "incident", "s", "b") { |
| + t.Fatal("an unwired pager reported a page") |
| + } |
| + }) |
| +} |
diff --git a/internal/host/movespass.go b/internal/host/movespass.go| index 5d7712bc1..f516507bb 100644 |
| --- a/internal/host/movespass.go |
| +++ b/internal/host/movespass.go |
| @@ -652,9 +652,11 @@ type moveCfg struct { |
| // Page pages an operator (moves spec §13, K11): PageOperator in |
| // production (armMovesPass), nil in tests, where nothing pages. The box |
| - // arms call it where a MoveMarkStuck lands (pageStuck): the rollback's |
| - // return on the target, and the recovery, its dirty return's install |
| - // and the revert on the source. |
| + // arms call it once per rule through the op journal (movespaging.go): |
| + // where a MoveMarkStuck lands (pageStuck: the rollback's return on the |
| + // target, and the recovery, its dirty return's install and the revert |
| + // on the source), where this box closes a rollback or a revert, and |
| + // where the cut's stop evidence finds a stray process. |
| Page func(ctx context.Context, level, subject, body string) error |
| // The source's recovery after a rollback (§7.8, movesrecover.go). |
diff --git a/internal/host/movesprobeowners.go b/internal/host/movesprobeowners.go| index 3ea0cf25e..b92ce3ca1 100644 |
| --- a/internal/host/movesprobeowners.go |
| +++ b/internal/host/movesprobeowners.go |
| @@ -127,6 +127,10 @@ type movesProbeOwners struct { |
| Release func(ctx context.Context, rt registry.Route, version string, work hostwrite.Work) error |
| NewID func() string |
| Now func() time.Time |
| + // Page pages an operator for a refused start check, once per op |
| + // (movespaging.go, §13): PageOperator once claim Plan 10 registers this |
| + // owner set; nil, nothing pages. |
| + Page func(ctx context.Context, level, subject, body string) error |
| mu sync.Mutex |
| published map[string]ClaimPublicationReceipt |
| @@ -857,7 +861,13 @@ func (m *movesProbeOwners) startCheck(ctx context.Context, o hostclaim.Observed, |
| if err != nil { |
| return err |
| } |
| - return moves.StartCheck(fresh.Claim, marker, files, db) |
| + if err := moves.StartCheck(fresh.Claim, marker, files, db); err != nil { |
| + // A refused start check pages an operator once per op (§13, |
| + // movespaging.go). |
| + m.pageStartRefused(ctx, o.Host, fresh.Claim.Op.ID) |
| + return err |
| + } |
| + return nil |
| } |
| // readMarker is the location's current generation.json, nil when absent. |
diff --git a/internal/host/movesrecover.go b/internal/host/movesrecover.go| index 6c81656fd..5b518ad59 100644 |
| --- a/internal/host/movesrecover.go |
| +++ b/internal/host/movesrecover.go |
| @@ -820,6 +820,7 @@ func (r *recoverRun) finalize(ctx context.Context) (bool, error) { |
| } |
| r.w.logTransition(ctx, r.s, pre, *closed) |
| log.Printf("moves: %s: move %s is rolled back and closed", host, id) |
| + r.w.pageRolledBack(ctx, r.rec, r.rb) |
| nudgeSerial(ctx, r.s) |
| if !released { |
| if err := r.fault("after-close-record"); err != nil { |
diff --git a/internal/host/movesrevert.go b/internal/host/movesrevert.go| index 23c381cf0..3f21b694c 100644 |
| --- a/internal/host/movesrevert.go |
| +++ b/internal/host/movesrevert.go |
| @@ -726,6 +726,7 @@ func (r *revertRun) close(ctx context.Context, releaseClaim bool) (bool, error) |
| return false, err |
| } |
| log.Printf("moves: %s: move %s is reverted and closed", host, id) |
| + r.w.pageRolledBack(ctx, r.rec, id) |
| nudgeSerial(ctx, r.s) |
| if releaseClaim { |
| if err := r.fault("after-close-record"); err != nil { |
diff --git a/internal/host/movesrollback.go b/internal/host/movesrollback.go| index b9a9eea6d..8d4e0775f 100644 |
| --- a/internal/host/movesrollback.go |
| +++ b/internal/host/movesrollback.go |
| @@ -14,7 +14,6 @@ import ( |
| "strings" |
| "time" |
| - "github.com/carlosframework/platform/internal/alerting" |
| "github.com/carlosframework/platform/internal/appconfig" |
| "github.com/carlosframework/platform/internal/hostbox" |
| "github.com/carlosframework/platform/internal/hostclaim" |
| @@ -125,30 +124,7 @@ func (w *movesWorker) now() time.Time { |
| return time.Now() |
| } |
| -// pageStuck pages an operator once a MoveMarkStuck has landed (moves spec |
| -// §13: stuck is an incident; K11). role is this box's part in the move, |
| -// "source" or "target", and reason the recorded stuck sentence. The page |
| -// names the host, the move, the box and the reason, and nothing else: no |
| -// path, no error text, no secret. Delivery is best effort: a replay that |
| -// lands the same mark again pages again, and a crash between the mark and |
| -// the send, or a send that fails (logged, never retried), pages nothing. |
| -// Unwired (nil Page), nothing pages; a failed page never stops the arm. |
| -func (w *movesWorker) pageStuck(ctx context.Context, rec provision.Record, role, reason string) { |
| - f := w.cfg.Page |
| - if f == nil { |
| - return |
| - } |
| - id := "" |
| - if rec.Move != nil { |
| - id = rec.Move.ID |
| - } |
| - subject, body := stuckPage(rec.Host, id, w.cfg.Box, role, reason) |
| - if err := f(ctx, alerting.LevelIncident, subject, body); err != nil { |
| - log.Printf("moves: %s: the page for the stuck mark of %s: %v", rec.Host, id, err) |
| - } |
| -} |
| - |
| -// stuckPage is the stuck mark's page: the subject (MV-N313) and the body |
| +// stuckPage is the stuck mark's page (pageStuck, movespaging.go): the subject (MV-N313) and the body |
| // (MV-N314). |
| func stuckPage(host, id, box, role, reason string) (subject, body string) { |
| subject = fmt.Sprintf("carlos move stuck: %s", host) |
diff --git a/internal/provision/plan9_b8_census_test.go b/internal/provision/plan9_b8_census_test.go| index 26c405d3a..96cfdbf27 100644 |
| --- a/internal/provision/plan9_b8_census_test.go |
| +++ b/internal/provision/plan9_b8_census_test.go |
| @@ -543,8 +543,8 @@ var plan9B8CensusInventory = []censusEntry{ |
| {"internal/host/moveslandcommit.go", "landingRun.open", "record-writer", "key:SetRecordMove", "febac996d3bb9caf4021cbc4bcffe1c900bddba4287ff95f083bd5e0751e9bc2"}, |
| // Closes a committed move through SetRecordMove (MoveClose, LastMove done) once nothing is owed under the op, then releases the claim (ReleaseMove via releaseAfterClose): record first, ruling M4. A claim already released by this move gets only the record's close. |
| {"internal/host/movesparks.go", "movesWorker.closeMove", "record-writer", "key:SetRecordMove", "380a2a0f8cc450573256f359a77da5e451ad506fef84aaeceb9696e3d2904211"}, |
| - // Closes the rollback record through SetRecordMove once nothing is owed under the op, then releases the claim (record first, ruling M4); local journal parking uses separate storage. |
| - {"internal/host/movesrecover.go", "recoverRun.finalize", "record-writer", "key:SetRecordMove", "509591bb643d58d9314314d4d7c3fd245468502317de750d9e7b28e7a3cd2673"}, |
| + // Closes the rollback record through SetRecordMove once nothing is owed under the op, then releases the claim (record first, ruling M4); local journal parking uses separate storage. Plan 6 Task 5: after the close it pages rolled_back once (pageRolledBack, the recovery's journal), which writes no record. |
| + {"internal/host/movesrecover.go", "recoverRun.finalize", "record-writer", "key:SetRecordMove", "34fd48b2d014c5b1cf9480ae93f9e2c0fa6ff1f1ed7600cc34ee495e29c49315"}, |
| // Advances the source recovery phase through SetRecordMove and its sequenced writer. |
| {"internal/host/movesrecover.go", "recoverRun.phase", "record-writer", "key:SetRecordMove", "be641eaba77b940ce471696720b637b9dfb9454de0893aca1fcc40750135719d"}, |
| // Records probe inputs through SetRecordMove; phase and stuck helpers use the same writer. |