v0.263.0: a failed update puts the app back by itself (09 decision 15, R-637)
gates / gates (push) Successful in 26s

The guarded update gains a folder copy of the app's named volumes, taken
after the pull where the app stops anyway (decision 19, chosen by the
2026-09-23 bake-off). On a failed health check the box undoes: every copy
validated by its finished-marker first, volumes refilled, definition and pin
from the job's own pre-update copies, the old version checked with the OLD
.felhom.yml probe. It holds only if the undo fails, and the hold sentence
says so and what state the data is in. Bind-mounted folders are never
touched.

- R-637 built; R-638/R-640/R-641 do not arise with a folder copy; R-639
  (pre-update copies incl. .felhom.yml kept until the undo is over).
- journal phases copying/undoing with power-cut recovery.
- app.yaml last_update_undone + one line on the app page (hu/en).
- R-642: start/restart never answer "completed".
- Removal deletes kept undo copies.

MinAgent unchanged (0.131.0). Nine red-proofs in REPORT.md.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-09-23 11:12:49 +02:00
parent b9deec1907
commit 8fc2b4a1a9
26 changed files with 1326 additions and 69 deletions
@@ -0,0 +1,50 @@
package api
import (
"go/ast"
"go/parser"
"go/token"
"strings"
"testing"
)
// R-642 (v0.263.0): a start or restart is never answered "completed" — measured 2026-09-23 on docmost
// and romm, both 200 "start completed" while `Restarting (1)` behind a 404 front door.
//
// COMPANION RED-PROOF (REPORT.md): put `Message: "Stack " + name + " " + action + " completed"` back in
// startAnswer — the first assertion fails.
func TestR642_StartIsNeverReportedCompleted(t *testing.T) {
for _, action := range []string{"start", "restart"} {
a := startAnswer("docmost", action, "restarting")
if strings.Contains(a.Message, "completed") {
t.Errorf("%s answered %q — a start cannot know it completed", action, a.Message)
}
if !strings.Contains(a.Message, "restarting") {
t.Errorf("%s must say the state it sees, got %q", action, a.Message)
}
if d, _ := a.Data.(map[string]interface{}); d["state"] != "restarting" {
t.Errorf("%s data = %v", action, a.Data)
}
}
}
// TestR642_TheHandlerUsesStartAnswer walks the router's AST: the stack-action handler must CALL
// startAnswer (a comment naming it would not count — the v0.154.0 / R-106 inert-seam class).
func TestR642_TheHandlerUsesStartAnswer(t *testing.T) {
f, err := parser.ParseFile(token.NewFileSet(), "router.go", nil, 0)
if err != nil {
t.Fatal(err)
}
calls := 0
ast.Inspect(f, func(n ast.Node) bool {
if c, ok := n.(*ast.CallExpr); ok {
if id, ok := c.Fun.(*ast.Ident); ok && id.Name == "startAnswer" {
calls++
}
}
return true
})
if calls != 1 {
t.Errorf("startAnswer must be called exactly once from router.go, found %d", calls)
}
}
+23 -1
View File
@@ -731,7 +731,21 @@ func (r *Router) actionStack(w http.ResponseWriter, req *http.Request, action, n
Data: map[string]interface{}{"accepted": true, "completed": false}})
return
}
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Stack " + name + " " + action + " completed"})
// R-642 (v0.263.0): a start or restart is NEVER reported "completed". `compose up -d` returns 0
// over an app that is about to crash-loop — measured 2026-09-23 on docmost and romm, each answering
// 200 "start completed" while `Restarting (1)` behind a 404 front door. What this knows at return is
// the state the containers are in right now, so that is what it says; whether the APP works is
// the health probe's to answer, not this line's. Pinned by TestR642_StartIsNeverReportedCompleted.
if action == "start" || action == "restart" {
state := ""
_ = r.stackMgr.RefreshStatus()
if st, ok := r.stackMgr.GetStack(name); ok {
state = string(st.State)
}
writeJSON(w, http.StatusOK, startAnswer(name, action, state))
} else {
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Stack " + name + " " + action + " completed"})
}
// Trigger integration lifecycle hooks after successful action
if r.integrationMgr != nil {
@@ -1505,3 +1519,11 @@ func writeJSON(w http.ResponseWriter, status int, v interface{}) {
func limitBody(w http.ResponseWriter, req *http.Request) {
req.Body = http.MaxBytesReader(w, req.Body, 1<<20) // 1MB
}
// startAnswer is R-642's answer for a start or restart: what was requested and the state the
// containers are in right now — never "completed", which nothing at this point can know.
func startAnswer(name, action, state string) apiResponse {
return apiResponse{OK: true,
Message: "Stack " + name + " " + action + " requested — state now: " + state,
Data: map[string]interface{}{"state": state}}
}
@@ -49,12 +49,14 @@ func (g *apiFakeGuards) RestorePoints(_ context.Context, _ string, accept func(s
}
return stacks.UpdateRestorePoint{}, false, g.points
}
func (g *apiFakeGuards) CanBackUp(string) (bool, string) { return !g.cannotBackUp, "fake: no drive" }
func (g *apiFakeGuards) CanBackUp(string) (bool, string) { return !g.cannotBackUp, "fake: no drive" }
func (g *apiFakeGuards) BackupNow(context.Context, string) error { return nil }
func (g *apiFakeGuards) SafetyDump(context.Context, string) ([]string, error) {
return nil, nil
}
func (g *apiFakeGuards) HoldAfterFailedUpdate(string, time.Time, stacks.UpdateRestorePoint) error { return nil }
func (g *apiFakeGuards) HoldAfterFailedUpdate(string, time.Time, stacks.UpdateRestorePoint, string) error {
return nil
}
const slice4AppYAML = "deployed: true\nenv: {}\npinned_images:\n app: nginx:1.27\n"
@@ -113,7 +115,7 @@ func postUpdate(t *testing.T, r *Router) (int, apiResponse) {
func TestR439_UpdateOfAHeldAppIsRefused(t *testing.T) {
r, sett, g, dir := newSlice4Router(t)
g.points, g.cannotBackUp = nil, true // the preflight's own refusal would say "no backup" — not the hold
g.blindToHolds = true // only the router's line can produce the hold's sentence
g.blindToHolds = true // only the router's line can produce the hold's sentence
if err := sett.SetRestoreHold(settings.RestoreHold{Stack: "app", At: "2026-09-13T08:00:00Z", Reason: settings.HoldReasonUpdateFailed, CopyDate: "2026-09-13T01:30:00Z"}); err != nil {
t.Fatal(err)
}