diff --git a/cmd/orchestra-worker/main.go b/cmd/orchestra-worker/main.go index d781649..3481a25 100644 --- a/cmd/orchestra-worker/main.go +++ b/cmd/orchestra-worker/main.go @@ -475,6 +475,19 @@ func (w *worker) start(ctx context.Context, t domain.Task, ref string) error { } log.Printf("launch %s confirmed: %s", t.ID, evidence) } + // Baseline the progress check at launch, not at the first renewal. The + // renewal gate exempts a lease with no baseline, which handed a pane that + // opened and never started a full free renewal period (F34) — the exact + // case the gate exists to catch. Capturing here costs one pane read and + // makes the first renewal a real comparison. + if l, ok := w.leases[t.ID]; ok && l.ProgressSHA == "" { + if text, progressErr := w.paneProgress(ctx, herdr.CLIAdapter{Backend: backend, Harness: w.harness}, s); progressErr == nil { + l.ProgressSHA = domain.Hash([]byte(text)) + w.leases[t.ID] = l + } else { + w.recordError(fmt.Errorf("baseline launch progress %s: %w", t.ID, progressErr)) + } + } if l, ok := w.leases[t.ID]; ok { if err := w.api.Start(ctx, t.ID, l.Epoch, l.Version, w.sessionEvidence(ctx, t.ID, s)); err != nil { return fmt.Errorf("ack start: %w", err) @@ -1093,6 +1106,19 @@ func (w *worker) runCommands(ctx context.Context) { _ = w.api.ResolveCommand(ctx, command.ID, "stale", "capture revision changed") continue } + if command.Kind == "resubmit" { + // Not an approval: the pane is holding input Orchestra already + // submitted and believes it delivered. Press Enter and say so. + if err := w.executionBackend().SendKeys(ctx, session, []string{"Enter"}); err != nil { + _ = w.api.ResolveCommand(ctx, command.ID, "rejected", "resubmit not delivered: "+err.Error()) + continue + } + log.Printf("resubmit %s: Enter resent to %s at capture revision %d", command.TaskID, session.PaneID, command.CaptureRevision) + if err := w.api.ResolveCommand(ctx, command.ID, "acknowledged", ""); err != nil { + log.Printf("ack command %s: %v", command.ID, err) + } + continue + } input, ok := approvalResponse(text, command.Kind) if !ok { _ = w.api.ResolveCommand(ctx, command.ID, "rejected", "prompt does not expose an executable approval control") diff --git a/internal/federation/federation.go b/internal/federation/federation.go index 1d5d7a2..afcbc78 100644 --- a/internal/federation/federation.go +++ b/internal/federation/federation.go @@ -237,7 +237,11 @@ func (r *Registry) Queue(worker string, c Command) (Command, error) { if _, ok := r.workers[worker]; !ok { return Command{}, ErrUnknownWorker } - if c.TaskID == "" || c.PaneID == "" || c.CaptureRevision == 0 || (c.Kind != "grant_approval" && c.Kind != "deny_approval") { + // "resubmit" presses Enter on input Orchestra already submitted and the + // harness never took (F33). It carries no decision, so it is not an + // approval, but it is fenced the same way: a live pane and the capture + // revision the operator was looking at. + if c.TaskID == "" || c.PaneID == "" || c.CaptureRevision == 0 || (c.Kind != "grant_approval" && c.Kind != "deny_approval" && c.Kind != "resubmit") { return Command{}, errors.New("invalid control command") } c.ID = fmt.Sprintf("cmd-%x", sha256.Sum256([]byte(fmt.Sprintf("%s/%s/%s/%d/%d", worker, c.TaskID, c.Kind, c.CaptureRevision, time.Now().UnixNano()))))[:20] diff --git a/internal/ui/ui.go b/internal/ui/ui.go index f32b5b1..1a0de44 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -210,7 +210,7 @@ func capturePane(s Server, t domain.Task, c *Capture) string { } func actions(t domain.Task) []Action { active := t.State == domain.StateLeased - return []Action{{ID: "handoff", Enabled: active, Reason: "requires a live leased session"}, {ID: "release", Enabled: active, Needs: []string{"reason or handoff_ref"}}, {ID: "block", Enabled: active, Needs: []string{"blocker"}}, {ID: "complete", Enabled: active, Needs: []string{"report_ref", "receipt"}}} + return []Action{{ID: "handoff", Enabled: active, Reason: "requires a live leased session"}, {ID: "resubmit", Enabled: active, Reason: "resends Enter to a pane still holding unsent input"}, {ID: "release", Enabled: active, Needs: []string{"reason or handoff_ref"}}, {ID: "block", Enabled: active, Needs: []string{"blocker"}}, {ID: "complete", Enabled: active, Needs: []string{"report_ref", "receipt"}}} } func (s Server) Overview(ctx context.Context) Overview { // JSON null is not an empty collection to browser clients. In particular, @@ -400,6 +400,33 @@ func (s Server) action(w http.ResponseWriter, r *http.Request, id, action string d, _ := s.detail(r.Context(), id) writeJSON(w, d) return + case "resubmit": + // Orchestra can put text in an editor and be wrong about whether it + // landed (F33). Until this existed the only way to recover a pane + // holding unsent input was to destroy the lease and wait for expiry, + // which cost the better part of an hour and threw away the session. + // + // This presses Enter on text Orchestra itself submitted. It changes no + // lifecycle state, so it appends no event; the worker logs the + // keystroke and the capture revision fences it against a pane that has + // moved on since the operator looked. + c, err := s.capture(r.Context(), t) + if err != nil || c == nil { + http.Error(w, "current capture unavailable", 503) + return + } + pane := capturePane(s, t, c) + if pane == "" || c.Source != "worker" { + http.Error(w, "resubmit needs a live worker-owned pane", 409) + return + } + command, err := s.Workers.Queue(t.Lease.HarnessID, federation.Command{TaskID: id, Kind: "resubmit", PaneID: pane, CaptureRevision: c.Revision}) + if err != nil { + http.Error(w, err.Error(), 409) + return + } + writeJSON(w, map[string]any{"command": command, "task": t}) + return case "handoff": if s.Coordinator == nil { http.Error(w, "live coordinator unavailable", 503) diff --git a/internal/ui/ui_test.go b/internal/ui/ui_test.go index 2fe4f7c..1fa39b4 100644 --- a/internal/ui/ui_test.go +++ b/internal/ui/ui_test.go @@ -187,3 +187,51 @@ func TestActionWithoutABodyDoesNotPanic(t *testing.T) { t.Fatalf("block status=%d body=%s, want 409 blocker required", r.Code, r.Body.String()) } } + +// TestResubmitQueuesAKeystrokeWithoutTouchingLifecycle covers the gap F33 left +// behind: a pane holding unsent input had no recovery short of destroying the +// lease and waiting out expiry. Resubmit presses Enter on text Orchestra +// already submitted, so it queues a worker command and appends no event. +func TestResubmitQueuesAKeystrokeWithoutTouchingLifecycle(t *testing.T) { + s, err := store.Open(t.TempDir()) + if err != nil { + t.Fatal(err) + } + workers := &federation.Registry{} + if err := workers.Register(federation.Worker{ID: "wpc-claude", Token: "tok"}, ""); err != nil { + t.Fatal(err) + } + h := Server{Store: s, Workers: workers}.Handler() + req := httptest.NewRequest(http.MethodPost, "/v1/ui/tasks", bytes.NewBufferString(`{"source":"web","external_id":"stuck","project":"demo","title":"Stuck launch"}`)) + r := httptest.NewRecorder() + h.ServeHTTP(r, req) + id := s.Tasks()[0].ID + if _, err := s.Lease(id, "wpc-claude", time.Hour); err != nil { + t.Fatal(err) + } + before := len(s.Events(0)) + capture, err := workers.PutCapture("wpc-claude", federation.Capture{TaskID: id, PaneID: "wA:p1", Text: "> read .orchestra/launch.md now"}) + if err != nil { + t.Fatal(err) + } + req = httptest.NewRequest(http.MethodPost, "/v1/ui/tasks/"+id+"/actions/resubmit", bytes.NewBufferString(`{}`)) + r = httptest.NewRecorder() + h.ServeHTTP(r, req) + if r.Code != http.StatusOK { + t.Fatalf("resubmit status=%d body=%s", r.Code, r.Body.String()) + } + pending, err := workers.Commands("wpc-claude") + if err != nil || len(pending) != 1 { + t.Fatalf("commands=%#v err=%v", pending, err) + } + if pending[0].Kind != "resubmit" || pending[0].PaneID != "wA:p1" || pending[0].CaptureRevision != capture.Revision { + t.Fatalf("queued %#v, want a resubmit bound to wA:p1 at revision %d", pending[0], capture.Revision) + } + if got := len(s.Events(0)); got != before { + t.Fatalf("events %d -> %d: a keystroke must not append a lifecycle event", before, got) + } + task, _ := s.Task(id) + if task.State != domain.StateLeased { + t.Fatalf("state=%q, want the lease untouched", task.State) + } +}