moves: a release-time archive refusal stops for an operator (Plan 5 Task 9a, step 4)
A target that served stops for a rollback and digests its files for the stopped report. Files the archive can never carry (a special file, over the cap, too many entries, a path too long) made the release retry forever. They now stop the record for an operator: - provision: StuckReleaseTargetFiles, legal for a rollback whose target is at releasing with the source waiting; the target keeps its phase and a retry runs the release again; - host: rollbackRun.releasing classifies the digest's error (archiveRefused) and marks it stuck through rollbackRun.stuck, which pages once through moveCfg.Page (the stuck page, MV-N313/314); a change under the walk or a disk error still retries. Copy: MV-N335 (pending), the recorded sentence. Census: rollbackRun.stuck's digest changes for its log line only (the write is the same MoveMarkStuck). Tests: TestStuckReleaseTargetFiles (provision), TestReleaseTimeArchiveRefusalGoesStuck (host).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
7 files changed,
+121
−5
approved_moves_copy_test.go+1 −0docs/superpowers/plans/2026-10-06-moves-phase1-copy.md+6 −0internal/host/movescarries_test.go+34 −0internal/host/movesrollback.go+20 −3internal/provision/move_test.go+44 −0internal/provision/plan9_b8_census_test.go+2 −2internal/provision/provision.go+14 −0
diff --git a/approved_moves_copy_test.go b/approved_moves_copy_test.go| index 34c1f3ba9..72bd4af23 100644 |
| --- a/approved_moves_copy_test.go |
| +++ b/approved_moves_copy_test.go |
| @@ -76,6 +76,7 @@ var movesCopyIDs = []string{ |
| "MV-N313", "MV-N314", |
| "MV-N315", "MV-N316", "MV-N317", "MV-N318", |
| "MV-N325", "MV-N326", "MV-N327", "MV-N328", "MV-N329", "MV-N330", "MV-N331", "MV-N332", "MV-N333", "MV-N334", |
| + "MV-N335", |
| } |
| // copyRow is one row of the record. The File cell is the file the sentence is |
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 4651f5fb7..17e095ec5 100644 |
| --- a/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md |
| +++ b/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md |
| @@ -286,6 +286,12 @@ Task 7's pages, the console's move pass (`internal/console/movesrecovery.go`, § |
| | MV-N333 | internal/console/movesrecovery.go | carlos move abort not closed: <host> | pending | |
| | MV-N334 | internal/console/movesrecovery.go | The abort of the move <id> of <host> has nothing left to park in <to>, and <box> has not closed it. <box> closes it once it has removed the move's snapshots from its own bucket. Check <box>. | pending | |
| +Task 9a's rows (the production park wiring). MV-N335 is the stuck reason a rollback records when the target's files cannot be archived at its release; it reaches `carlos move status` through Move.StuckReason and the stuck page (MV-N314). |
| + |
| +| id | file | text | status | |
| +|---|---|---|---| |
| +| MV-N335 | internal/provision/provision.go | the target's files cannot be archived now that it has stopped | pending | |
| + |
| ## Floor gate fix (#267 review) |
| (root's floor gate for the system units, `carlos system floor-allows-prev`; its stderr reaches the unit's journal) |
diff --git a/internal/host/movescarries_test.go b/internal/host/movescarries_test.go| index d7df5e002..893ee191a 100644 |
| --- a/internal/host/movescarries_test.go |
| +++ b/internal/host/movescarries_test.go |
| @@ -186,3 +186,37 @@ func TestFailedRenderAckIsParkedByCleaning(t *testing.T) { |
| }) |
| } |
| } |
| + |
| +// TestReleaseTimeArchiveRefusalGoesStuck (M16): a target that served stops, |
| +// and its files directory holds something the archive can never carry. The |
| +// release records the stuck reason at releasing (not a retry forever), the |
| +// operator is paged once through moveCfg.Page, and once the cause is |
| +// removed a retry runs the release again. |
| +func TestReleaseTimeArchiveRefusalGoesStuck(t *testing.T) { |
| + h := newRollbackHarness(t) |
| + h.rollBack(t) |
| + link := filepath.Join(h.tdir, p1Host, "link") |
| + if err := os.Symlink("/etc/passwd", link); err != nil { |
| + t.Fatal(err) |
| + } |
| + h.rollbackPass(t) |
| + m := h.read(t, p1Host).Move |
| + if m.State != provision.MoveStuck || m.StuckReason != provision.StuckReleaseTargetFiles || m.Rollback.TargetPhase != provision.RbTargetReleasing || |
| + m.Rollback.StuckAt != provision.RbSourceWaiting { |
| + t.Fatalf("not stuck at releasing: %+v %+v", m, m.Rollback) |
| + } |
| + pagedOnce(t, h.pages, "uk-box", "target", provision.StuckReleaseTargetFiles) |
| + // A stuck record is not the target's to work on, and pages no more. |
| + h.rollbackPass(t) |
| + if got := h.read(t, p1Host).Move; got.State != provision.MoveStuck || len(h.pages) != 1 { |
| + t.Fatalf("a pass over the stuck record: %+v, %d pages", got, len(h.pages)) |
| + } |
| + if err := os.Remove(link); err != nil { |
| + t.Fatal(err) |
| + } |
| + h.retry(t) |
| + h.rollbackPass(t) |
| + if rb := h.rb(t); h.read(t, p1Host).Move.State != provision.MoveRollingBack || rb.TargetPhase == provision.RbTargetReleasing { |
| + t.Fatalf("after the retry: %+v", rb) |
| + } |
| +} |
diff --git a/internal/host/movesrollback.go b/internal/host/movesrollback.go| index f79d2eadc..c888a781e 100644 |
| --- a/internal/host/movesrollback.go |
| +++ b/internal/host/movesrollback.go |
| @@ -21,6 +21,7 @@ import ( |
| "github.com/carlosframework/platform/internal/hostguard" |
| "github.com/carlosframework/platform/internal/lease" |
| "github.com/carlosframework/platform/internal/moves" |
| + "github.com/carlosframework/platform/internal/moves/archive" |
| "github.com/carlosframework/platform/internal/park" |
| "github.com/carlosframework/platform/internal/provision" |
| "github.com/carlosframework/platform/internal/release" |
| @@ -513,6 +514,13 @@ func (r *rollbackRun) releasing(ctx context.Context) (bool, error) { |
| dir, envPath := cfg.FilesDir(host) |
| plain, _, _, err := archivePlainDigest(dir, envPath) |
| if err != nil { |
| + if reached && archiveRefused(err) { |
| + // Files the archive can never carry: no retry by itself can |
| + // help, so the release stops for an operator (Plan 5 Task 9a), |
| + // keeping its phase and everything the target holds. |
| + return r.stuck(ctx, &returnRefusal{reason: provision.StuckReleaseTargetFiles, |
| + err: fmt.Errorf("move %s: the target's stopped files cannot be archived: %w", id, err)}) |
| + } |
| if reached { |
| return false, fmt.Errorf("digest the stopped files: %w", err) |
| } |
| @@ -871,6 +879,15 @@ func (e *returnRefusal) Unwrap() error { return e.err } |
| func refuseReturn(reason string, err error) error { return &returnRefusal{reason: reason, err: err} } |
| +// archiveRefused reports whether err is one the archive gives for files it |
| +// can never carry (a special file, over the cap, too many entries, a path |
| +// too long), as opposed to a change under the walk or the disk's error, |
| +// which a retry may get past. |
| +func archiveRefused(err error) bool { |
| + return errors.Is(err, archive.ErrSpecialFile) || errors.Is(err, archive.ErrOverCap) || |
| + errors.Is(err, archive.ErrTooManyEntries) || errors.Is(err, archive.ErrBadPath) |
| +} |
| + |
| // databaseGone reports whether err says the database's data is not there to |
| // read (absent, short, or not a database), as opposed to a read that failed: |
| // an *os.PathError other than "does not exist" is the disk's, and transient. |
| @@ -883,11 +900,11 @@ func databaseGone(err error) bool { |
| } |
| // stuck stops the record for an operator (MoveMarkStuck) at the target's |
| -// returning: the phase, the row and everything the target holds are kept, |
| -// and move --retry runs the return again. |
| +// releasing or returning: the phase, the row and everything the target |
| +// holds are kept, and move --retry runs that phase again. |
| func (r *rollbackRun) stuck(ctx context.Context, refused *returnRefusal) (bool, error) { |
| host := r.rec.Host |
| - log.Printf("moves: %s: the return of %s cannot be built (%v); it stops for an operator: %s", host, r.rb, refused.err, refused.reason) |
| + log.Printf("moves: %s: the target's half of %s cannot go on (%v); it stops for an operator: %s", host, r.rb, refused.err, refused.reason) |
| if _, err := provision.SetRecordMove(ctx, r.s, r.rec.Account, r.rec.App, host, provision.MoveMarkStuck(r.g, refused.reason)); err != nil { |
| if errors.Is(err, provision.ErrMoveConflict) { |
| log.Printf("moves: %s: the stuck mark of %s refused (%v); the record moved on", host, r.rb, err) |
diff --git a/internal/provision/move_test.go b/internal/provision/move_test.go| index e7cdd0aa0..4f0ddd1ff 100644 |
| --- a/internal/provision/move_test.go |
| +++ b/internal/provision/move_test.go |
| @@ -1415,6 +1415,50 @@ func TestStuckReturnTargetReasons(t *testing.T) { |
| } |
| } |
| +// TestStuckReleaseTargetFiles (Plan 5 Task 9a): the target's release finds |
| +// files it can never archive. The reason belongs to the target's releasing, |
| +// with the source still waiting, dirty decided or not; the target phase |
| +// stays releasing, the source's waiting is StuckAt, and a retry runs the |
| +// release again. Anywhere else it is a conflict. |
| +func TestStuckReleaseTargetFiles(t *testing.T) { |
| + g := cutTestGuard() |
| + now := time.Date(2026, 10, 6, 9, 0, 0, 0, time.UTC) |
| + at := func(target string, dirty bool) *Record { |
| + rec := rollingBackRecord(t, g, now) |
| + rec.Move.Rollback.TargetPhase, rec.Move.Rollback.Dirty = target, dirty |
| + return rec |
| + } |
| + for _, dirty := range []bool{false, true} { |
| + rec := at(RbTargetReleasing, dirty) |
| + if err := MoveMarkStuck(g, StuckReleaseTargetFiles)(rec); err != nil { |
| + t.Fatalf("dirty %v: %v", dirty, err) |
| + } |
| + m, rb := rec.Move, rec.Move.Rollback |
| + if m.State != MoveStuck || m.StuckReason != StuckReleaseTargetFiles || rb.StuckAt != RbSourceWaiting || rb.TargetPhase != RbTargetReleasing { |
| + t.Fatalf("dirty %v: %+v %+v", dirty, m, rb) |
| + } |
| + if err := MoveMarkStuck(g, StuckReleaseTargetFiles)(rec); err != nil { |
| + t.Fatalf("replay: %v", err) |
| + } |
| + if err := MoveRetry(g)(rec); err != nil || m.State != MoveRollingBack || rb.SourcePhase != RbSourceWaiting || rb.TargetPhase != RbTargetReleasing || m.StuckReason != "" { |
| + t.Fatalf("retry: %v %+v %+v", err, m, rb) |
| + } |
| + } |
| + for _, target := range []string{RbTargetReleased, RbTargetReturning, RbTargetReturned, RbTargetCleaning} { |
| + if err := MoveMarkStuck(g, StuckReleaseTargetFiles)(at(target, true)); !errors.Is(err, ErrMoveConflict) { |
| + t.Fatalf("at %s: %v", target, err) |
| + } |
| + } |
| + rec := at(RbTargetReleasing, false) |
| + rec.Move.Rollback.SourcePhase = RbSourceClaimed |
| + if err := MoveMarkStuck(g, StuckReleaseTargetFiles)(rec); !errors.Is(err, ErrMoveConflict) { |
| + t.Fatalf("with the source claimed: %v", err) |
| + } |
| + if StuckReleaseTargetFiles == StuckReturnTargetFiles { |
| + t.Fatal("the release's reason must differ from the return's") |
| + } |
| +} |
| + |
| func TestRolledBackCloseNeedsAllThreeStamps(t *testing.T) { |
| g := cutTestGuard() |
| now := time.Date(2026, 10, 6, 9, 0, 0, 0, time.UTC) |
diff --git a/internal/provision/plan9_b8_census_test.go b/internal/provision/plan9_b8_census_test.go| index 94d068b69..8f90460d3 100644 |
| --- a/internal/provision/plan9_b8_census_test.go |
| +++ b/internal/provision/plan9_b8_census_test.go |
| @@ -555,8 +555,8 @@ var plan9B8CensusInventory = []censusEntry{ |
| {"internal/host/movesrollback.go", "rollbackRun.laneMoveJSON", "nonrecord", "sel:PutIfAbsentVersioned", "5fd541f4a1daf2600ff266583fc9627cc0cab35e276f1d9c8f097f128c2c4e53"}, |
| // Advances the target rollback phase through SetRecordMove and its sequenced writer. |
| {"internal/host/movesrollback.go", "rollbackRun.phase", "record-writer", "key:SetRecordMove", "76f1efb1076029e5da4c0cc0d750fbac5e1c94a6014c2b1e4af241797350827b"}, |
| - // Marks the return stuck through SetRecordMove; event and serial writes are not record objects. |
| - {"internal/host/movesrollback.go", "rollbackRun.stuck", "record-writer", "key:SetRecordMove", "84fcea399a55d77ceee17e35652ef269e5811453ffff62dff43208b68538b1ed"}, |
| + // Marks the return stuck through SetRecordMove; event and serial writes are not record objects. Plan 5 Task 9a: the release's archive refusal stops here too; only the log line changed (the target's half, not the return), the write is the same MoveMarkStuck. |
| + {"internal/host/movesrollback.go", "rollbackRun.stuck", "record-writer", "key:SetRecordMove", "fc699dcb2fc95356e6721be4ca849f2bd361bbef5f639ce54eac5fc92de2a6d6"}, |
| // Validates the pointer-digest marker key and Put/Delete method names without writing. |
| {"internal/hostops/mutation_classifier.go", "classifyPointerDigestMarker", "reference", "str:Put", "08111b5db5d98e788d1eef5861ad9c861cf926c5e1d5d54717fd650d2dcf9fa8"}, |
| // Validates the promotion-record key (releases/<acct>/<app>/promoted/<version>, a release object, not a |
diff --git a/internal/provision/provision.go b/internal/provision/provision.go| index 94542d08e..fdbe6cd23 100644 |
| --- a/internal/provision/provision.go |
| +++ b/internal/provision/provision.go |
| @@ -1865,6 +1865,13 @@ const ( |
| // archived (a special file, over the cap, too many entries, a path too |
| // long). |
| StuckReturnTargetFiles = "the target's files cannot be archived" |
| + // StuckReleaseTargetFiles (Plan 5 Task 9a): the target that served |
| + // stopped, and its files directory cannot be archived when it is |
| + // digested for its stopped report (a special file, over the cap, too |
| + // many entries, a path too long). A rollback whose target is at |
| + // releasing, the source still waiting; the target keeps its phase and |
| + // everything it holds, and a retry runs the release again. |
| + StuckReleaseTargetFiles = "the target's files cannot be archived now that it has stopped" |
| ) |
| // The refusals of this section that reach an operator through a door |
| @@ -2107,6 +2114,9 @@ func MoveRecordRecoveryInputs(g MoveGuard, rbID string, in MoveInputs) func(*Rec |
| // StuckReturnTargetFiles: a dirty rollback whose target is at returning |
| // (the source still waiting for it); the target phase is kept, so a |
| // retry runs the return again; |
| +// - StuckReleaseTargetFiles: a rollback whose target is at releasing, |
| +// the source still waiting (Plan 5 Task 9a); the target phase is kept, |
| +// so a retry runs the release again; |
| // - StuckRevertProbeBudget: a revert; |
| // - StuckRecoveryProbeAttempts: a revert too, whose probe has the recovery |
| // probe's nine attempts as well as its fifteen minutes (§7.7; Plan 4 |
| @@ -2143,6 +2153,8 @@ func MoveMarkStuck(g MoveGuard, reason string) func(*Record) error { |
| why = StuckReturnTargetChanged |
| case StuckReturnTargetFiles: |
| why = StuckReturnTargetFiles |
| + case StuckReleaseTargetFiles: |
| + why = StuckReleaseTargetFiles |
| default: |
| return fmt.Errorf("provision: stuck reason %q has no recorded sentence", reason) |
| } |
| @@ -2164,6 +2176,8 @@ func MoveMarkStuck(g MoveGuard, reason string) func(*Record) error { |
| ok = rb.Dirty && rb.SourcePhase == RbSourceConfigured |
| case StuckReturnTargetDatabase, StuckReturnTargetChanged, StuckReturnTargetFiles: |
| ok = rb.Dirty && rb.TargetPhase == RbTargetReturning && rb.SourcePhase == RbSourceWaiting |
| + case StuckReleaseTargetFiles: |
| + ok = rb.TargetPhase == RbTargetReleasing && rb.SourcePhase == RbSourceWaiting |
| } |
| } |
| case MoveReverting: |