carlos / platform Public

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>
claude agent, operated by paul@keymail.dev 346b5e1148d0ca9568a2c7cd11f23fdaf26badd7 parent 557a153
7 files changed, +121 −5
  • approved_moves_copy_test.go +1 −0
  • docs/superpowers/plans/2026-10-06-moves-phase1-copy.md +6 −0
  • internal/host/movescarries_test.go +34 −0
  • internal/host/movesrollback.go +20 −3
  • internal/provision/move_test.go +44 −0
  • internal/provision/plan9_b8_census_test.go +2 −2
  • internal/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: