Reconcile docs with reality; fix module graph, token compare, health #1
@@ -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")
|
||||
|
||||
@@ -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]
|
||||
|
||||
+28
-1
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user