Skip to content

Commit 959ad69

Browse files
authored
feat(plan): sacred-tier review feedback durability (0.11.1) (#690)
1 parent d057f0f commit 959ad69

24 files changed

Lines changed: 2198 additions & 59 deletions

.claude-plugin/marketplace.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
"name": "ox",
1313
"source": "./claude-plugin",
1414
"description": "Team context, session recording, and AI coworker coordination for collaborative development",
15-
"version": "0.11.0"
15+
"version": "0.11.1"
1616
}
1717
]
1818
}

claude-plugin/.claude-plugin/plugin.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "ox",
33
"description": "Team context, session recording, and AI coworker coordination for collaborative development",
4-
"version": "0.11.0",
4+
"version": "0.11.1",
55
"author": {
66
"name": "SageOx",
77
"email": "hi@sageox.ai"

cmd/ox/plan_review.go

Lines changed: 128 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -110,18 +110,59 @@ func runPlanReview(cmd *cobra.Command, slug string, noServe bool, idleTimeout ti
110110
return reviewStaticFallback(cmd, gitRoot, slug, in, res, review)
111111
}
112112

113-
ln, err := net.Listen("tcp", "127.0.0.1:0")
114-
if err != nil {
115-
cli.PrintHint("could not start review server, falling back to file export: " + err.Error())
116-
in := plan.Parse(planMD)
117-
review, _ := plan.AssembleReview(info.Dir)
118-
return reviewStaticFallback(cmd, gitRoot, slug, in, res, review)
113+
// Bind a STABLE address for this plan (persisted last port, then the
114+
// deterministic per-plan port). Stability is a durability feature, not a
115+
// nicety: the page's unsent marks live in origin-scoped localStorage, so a
116+
// restarted server on a fresh ephemeral port would strand them and leave the
117+
// old tab unable to ever reconnect. If a live server for this same plan is
118+
// already up, reuse it instead of racing it for the port.
119+
dirName := filepath.Base(info.Dir)
120+
state, _ := plan.LoadReviewServerState(gitRoot, dirName)
121+
var ln net.Listener
122+
for _, p := range reviewPortCandidates(state.Port, dirName) {
123+
l, lerr := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", p))
124+
if lerr == nil {
125+
ln = l
126+
break
127+
}
128+
if probeReviewServer(p, dirName) {
129+
url := fmt.Sprintf("http://127.0.0.1:%d/", p)
130+
fmt.Fprintf(cmd.OutOrStdout(), "%s %s\n", cli.StyleBold.Render("Review loop already open:"), url)
131+
cli.PrintHint("Reusing the running server — feedback keeps flowing to the same session.")
132+
if oerr := cli.OpenInBrowser(url); oerr != nil {
133+
cli.PrintHint("open this URL to review: " + url)
134+
}
135+
return nil
136+
}
137+
}
138+
if ln == nil {
139+
l, lerr := net.Listen("tcp", "127.0.0.1:0")
140+
if lerr != nil {
141+
cli.PrintHint("could not start review server, falling back to file export: " + lerr.Error())
142+
in := plan.Parse(planMD)
143+
review, _ := plan.AssembleReview(info.Dir)
144+
return reviewStaticFallback(cmd, gitRoot, slug, in, res, review)
145+
}
146+
ln = l
147+
cli.PrintHint("Stable review ports are busy — serving on an ephemeral port; a previously open tab won't auto-reconnect to this one.")
119148
}
120149
addr := ln.Addr().String()
121-
token, err := randomToken()
122-
if err != nil {
123-
_ = ln.Close()
124-
return fmt.Errorf("review server: %w", err)
150+
// Reuse the persisted token so a tab that outlived the last server
151+
// authenticates against this one without a reload (same trust domain: the
152+
// token only gates loopback callers and the state file is 0600).
153+
token := state.Token
154+
if token == "" {
155+
t, terr := randomToken()
156+
if terr != nil {
157+
_ = ln.Close()
158+
return fmt.Errorf("review server: %w", terr)
159+
}
160+
token = t
161+
}
162+
if tcp, ok := ln.Addr().(*net.TCPAddr); ok {
163+
if serr := plan.SaveReviewServerState(gitRoot, dirName, plan.ReviewServerState{Port: tcp.Port, Token: token}); serr != nil {
164+
slog.Debug("plan review: could not persist server state", "error", serr)
165+
}
125166
}
126167
base := "http://" + addr
127168

@@ -163,10 +204,13 @@ func runPlanReview(cmd *cobra.Command, slug string, noServe bool, idleTimeout ti
163204
return nil
164205
case <-idle.C:
165206
fmt.Fprintln(out, "\nReview session idle — closing.")
166-
cli.PrintHint("Re-open anytime with `ox plan review " + slug + "`; feedback is saved in the ledger.")
207+
cli.PrintHint("Everything submitted is saved in the ledger. The open page flips to disconnected mode; " +
208+
"unsent marks stay in the browser. Re-open anytime: `ox plan review " + slug + "` (same address — the page reconnects and restores them).")
167209
return nil
168210
case <-ctx.Done():
169211
fmt.Fprintln(out, "\nReview session closed.")
212+
cli.PrintHint("Everything submitted is saved in the ledger. The open page flips to disconnected mode; " +
213+
"unsent marks stay in the browser. Re-open anytime: `ox plan review " + slug + "` (same address — the page reconnects and restores them).")
170214
return nil
171215
}
172216
}
@@ -189,9 +233,35 @@ func liveReviewHandler(gitRoot, slug, planDir, base, token string, bc *broadcast
189233
return
190234
}
191235
w.Header().Set("Content-Type", "text/html; charset=utf-8")
236+
// no-store: offline serving is the service worker's job — the HTTP cache
237+
// must never mask a dead server with a stale page that still looks live.
238+
w.Header().Set("Cache-Control", "no-store")
192239
_, _ = w.Write(html)
193240
})
194241

242+
// /healthz: unauthenticated identity probe (loopback only). A second
243+
// `ox plan review` uses it to detect this server and reuse it; the page's
244+
// offline probe uses it to notice the server is back. Exposes nothing but
245+
// which plan is being served.
246+
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
247+
w.Header().Set("Content-Type", "application/json")
248+
w.Header().Set("Cache-Control", "no-store")
249+
_ = json.NewEncoder(w).Encode(reviewHealth{App: "ox-plan-review", Slug: slug, Dir: filepath.Base(planDir)})
250+
})
251+
252+
// /sw.js: the offline shell (assets/sw.js) — keeps the plan readable on a
253+
// reload after this process exits; review.js registers it.
254+
mux.HandleFunc("/sw.js", func(w http.ResponseWriter, r *http.Request) {
255+
js, err := plan.ReviewServiceWorkerJS()
256+
if err != nil {
257+
http.NotFound(w, r)
258+
return
259+
}
260+
w.Header().Set("Content-Type", "text/javascript; charset=utf-8")
261+
w.Header().Set("Cache-Control", "no-store")
262+
_, _ = w.Write(js)
263+
})
264+
195265
// SSE: EventSource can't set headers, so the token rides as a query param.
196266
mux.HandleFunc("/events", func(w http.ResponseWriter, r *http.Request) {
197267
if r.URL.Query().Get("t") != token {
@@ -444,6 +514,53 @@ func reviewStaticFallback(cmd *cobra.Command, gitRoot, slug string, in plan.Inpu
444514
return nil
445515
}
446516

517+
// reviewHealth is the /healthz body — enough for a second `ox plan review`
518+
// (and the page's reconnect probe) to recognize a live server for a plan.
519+
type reviewHealth struct {
520+
App string `json:"app"`
521+
Slug string `json:"slug"`
522+
Dir string `json:"dir"`
523+
}
524+
525+
// reviewPortCandidates orders the ports to try binding: the last port this
526+
// plan actually served on (persisted state — the origin an open tab is parked
527+
// on), then the plan's deterministic stable port and a few slots after it for
528+
// same-machine collisions. Deduped, order-preserving.
529+
func reviewPortCandidates(persisted int, dirName string) []int {
530+
stable := plan.StableReviewPort(dirName)
531+
raw := []int{persisted, stable, stable + 1, stable + 2, stable + 3, stable + 4}
532+
seen := map[int]bool{}
533+
var out []int
534+
for _, p := range raw {
535+
if p <= 0 || p > 65535 || seen[p] {
536+
continue
537+
}
538+
seen[p] = true
539+
out = append(out, p)
540+
}
541+
return out
542+
}
543+
544+
// probeReviewServer reports whether a live ox review server for THIS plan is
545+
// already listening on port — so a second `ox plan review` reuses it instead
546+
// of racing it for the port or silently serving a twin.
547+
func probeReviewServer(port int, dirName string) bool {
548+
c := &http.Client{Timeout: 700 * time.Millisecond}
549+
resp, err := c.Get(fmt.Sprintf("http://127.0.0.1:%d/healthz", port))
550+
if err != nil {
551+
return false
552+
}
553+
defer resp.Body.Close()
554+
if resp.StatusCode != http.StatusOK {
555+
return false
556+
}
557+
var h reviewHealth
558+
if jerr := json.NewDecoder(io.LimitReader(resp.Body, 4096)).Decode(&h); jerr != nil {
559+
return false
560+
}
561+
return h.App == "ox-plan-review" && h.Dir == dirName
562+
}
563+
447564
// randomToken returns a fresh 128-bit hex token, or an error. It FAILS CLOSED —
448565
// no static fallback — so the server never starts with a guessable token in the
449566
// exact failure mode where entropy is unavailable.

cmd/ox/plan_review_resume_test.go

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
package main
2+
3+
// Tests for the review server's restart/resume durability surface: the
4+
// stable-identity endpoints and the port selection that keep an open review
5+
// tab alive across server death.
6+
7+
import (
8+
"encoding/json"
9+
"io"
10+
"net"
11+
"net/http"
12+
"strings"
13+
"testing"
14+
15+
"github.com/sageox/ox/internal/plan"
16+
)
17+
18+
// TestReviewServer_HealthzIdentifiesPlan verifies /healthz is served without a
19+
// token and names the plan being served. Failure prevented: a second
20+
// `ox plan review` can't recognize the running server, so it races it for the
21+
// port; the page's offline probe can't detect recovery.
22+
func TestReviewServer_HealthzIdentifiesPlan(t *testing.T) {
23+
dir := t.TempDir()
24+
srv, _, _ := newTestReviewServer(t, dir)
25+
resp, err := http.Get(srv.URL + "/healthz")
26+
if err != nil {
27+
t.Fatalf("healthz: %v", err)
28+
}
29+
defer resp.Body.Close()
30+
if resp.StatusCode != http.StatusOK {
31+
t.Fatalf("healthz status = %d", resp.StatusCode)
32+
}
33+
var h reviewHealth
34+
if err := json.NewDecoder(resp.Body).Decode(&h); err != nil {
35+
t.Fatalf("decode: %v", err)
36+
}
37+
if h.App != "ox-plan-review" || h.Slug != "p" {
38+
t.Errorf("healthz identity = %+v", h)
39+
}
40+
}
41+
42+
// TestReviewServer_ServesOfflineShell verifies /sw.js is served as JS. Failure
43+
// prevented: the page registers a 404 as its service worker, so a reload while
44+
// the server is down shows a browser error page instead of the cached plan.
45+
func TestReviewServer_ServesOfflineShell(t *testing.T) {
46+
dir := t.TempDir()
47+
srv, _, _ := newTestReviewServer(t, dir)
48+
resp, err := http.Get(srv.URL + "/sw.js")
49+
if err != nil {
50+
t.Fatalf("sw.js: %v", err)
51+
}
52+
defer resp.Body.Close()
53+
if resp.StatusCode != http.StatusOK {
54+
t.Fatalf("sw.js status = %d", resp.StatusCode)
55+
}
56+
if ct := resp.Header.Get("Content-Type"); !strings.Contains(ct, "javascript") {
57+
t.Errorf("sw.js content-type = %q", ct)
58+
}
59+
body, _ := io.ReadAll(resp.Body)
60+
if !strings.Contains(string(body), "caches.match") {
61+
t.Error("sw.js must serve the cache-fallback shell")
62+
}
63+
}
64+
65+
// TestReviewServer_PageIsNoStore verifies the live page forbids HTTP caching —
66+
// offline serving is the service worker's job. Failure prevented: the HTTP
67+
// cache masks a dead server with a stale page that still looks live, hiding
68+
// the disconnected state from the reviewer.
69+
func TestReviewServer_PageIsNoStore(t *testing.T) {
70+
dir := t.TempDir()
71+
srv, _, _ := newTestReviewServer(t, dir)
72+
resp, err := http.Get(srv.URL + "/")
73+
if err != nil {
74+
t.Fatalf("GET /: %v", err)
75+
}
76+
defer resp.Body.Close()
77+
// the temp dir has no plan.md, so the render may 500 — the header contract
78+
// applies to the page route regardless of render success.
79+
if cc := resp.Header.Get("Cache-Control"); resp.StatusCode == http.StatusOK && cc != "no-store" {
80+
t.Errorf("page Cache-Control = %q, want no-store", cc)
81+
}
82+
}
83+
84+
// TestReviewPortCandidates_PersistedFirstThenStable verifies candidate order:
85+
// the persisted (tab-parked) port leads, the deterministic stable port and its
86+
// probe window follow, no dupes, no nonsense ports. Failure prevented: a
87+
// restart binds a fresh origin while the old one was free, stranding marks.
88+
func TestReviewPortCandidates_PersistedFirstThenStable(t *testing.T) {
89+
const dirName = "2026-07-01-my-plan"
90+
stable := plan.StableReviewPort(dirName)
91+
92+
got := reviewPortCandidates(51000, dirName)
93+
if got[0] != 51000 {
94+
t.Errorf("persisted port must lead: %v", got)
95+
}
96+
if got[1] != stable {
97+
t.Errorf("stable port must follow persisted: %v", got)
98+
}
99+
seen := map[int]bool{}
100+
for _, p := range got {
101+
if p <= 0 || p > 65535 || seen[p] {
102+
t.Fatalf("bad candidate list: %v", got)
103+
}
104+
seen[p] = true
105+
}
106+
// persisted == stable must dedupe, and zero persisted must drop out.
107+
if got := reviewPortCandidates(stable, dirName); got[0] != stable || len(got) != 5 {
108+
t.Errorf("dedupe failed: %v", got)
109+
}
110+
if got := reviewPortCandidates(0, dirName); got[0] != stable {
111+
t.Errorf("zero persisted must be skipped: %v", got)
112+
}
113+
}
114+
115+
// TestProbeReviewServer_MatchesOnlyThisPlan verifies the probe recognizes a
116+
// live server for the SAME plan and rejects everything else (other plan,
117+
// non-ox listener, dead port). Failure prevented: `ox plan review` "reuses" a
118+
// stranger's server and never starts its own.
119+
func TestProbeReviewServer_MatchesOnlyThisPlan(t *testing.T) {
120+
dir := t.TempDir()
121+
srv, _, _ := newTestReviewServer(t, dir) // serves planDir with Dir=base(dir)
122+
port := srv.Listener.Addr().(*net.TCPAddr).Port
123+
124+
dirName := dir[strings.LastIndex(dir, "/")+1:]
125+
if !probeReviewServer(port, dirName) {
126+
t.Error("probe must recognize a live server for the same plan")
127+
}
128+
if probeReviewServer(port, "2026-01-01-some-other-plan") {
129+
t.Error("probe must reject a server for a different plan")
130+
}
131+
// a dead port must not match (bind-then-close to get a free one).
132+
l, err := net.Listen("tcp", "127.0.0.1:0")
133+
if err != nil {
134+
t.Fatalf("listen: %v", err)
135+
}
136+
dead := l.Addr().(*net.TCPAddr).Port
137+
_ = l.Close()
138+
if probeReviewServer(dead, dirName) {
139+
t.Error("probe must reject a dead port")
140+
}
141+
}

cmd/ox/release_notes.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,19 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [0.11.1] - 2026-07-01
9+
10+
Review feedback on plans is sacred — this release makes sure none of it can be lost, no matter what happens to the review server, the browser tab, or the plan itself.
11+
12+
### Fixed
13+
14+
- **The review page now tells you the moment feedback stops being saved** — if the review server exits (idle timeout, Ctrl-C, a crash), the page immediately shows a clear "offline — feedback is NOT being saved" banner with the exact restart command to copy, instead of looking live while submissions silently fail. Unsent marks stay safely in the browser and are restored on reconnect.
15+
- **Restarting a review picks up exactly where it left off**`ox plan review` serves each plan at a stable address, so the tab you already had open reconnects on its own (marks intact) when you restart, and re-running the command against an already-open review reuses the running one instead of starting a stranded twin.
16+
- **Reloading the plan while the server is down still shows the plan** — the page keeps a local copy of itself, so a reload lands in clearly-marked disconnected mode instead of a browser error screen.
17+
- **Your marks follow the plan as it changes** — when an AI coworker updates a plan, open review notes are re-anchored onto the content they referred to, so a reworded heading no longer detaches your comment. Notes whose content truly disappeared stay visibly open and keep appearing in every digest — nothing is ever dropped.
18+
- **Ask about review feedback later** — reviewer notes on plans now appear in local search, so "what did Sam flag on the auth plan?" finds the reviewer's actual words, not just the plan.
19+
- **Two coworkers resolving review items at the same time no longer lose one of the updates.**
20+
821
## [0.11.0] - 2026-07-01
922

1023
### Added

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ require (
4242
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0
4343
go.opentelemetry.io/otel/sdk v1.43.0
4444
go.opentelemetry.io/otel/trace v1.43.0
45+
golang.org/x/net v0.52.0
4546
golang.org/x/sync v0.20.0
4647
golang.org/x/sys v0.42.0
4748
golang.org/x/term v0.41.0
@@ -178,7 +179,6 @@ require (
178179
go.yaml.in/yaml/v3 v3.0.4 // indirect
179180
golang.org/x/arch v0.8.0 // indirect
180181
golang.org/x/crypto v0.49.0 // indirect
181-
golang.org/x/net v0.52.0 // indirect
182182
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect
183183
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect
184184
google.golang.org/grpc v1.80.0 // indirect

0 commit comments

Comments
 (0)