From dbc39e71bb46e851adb32d27c33596cbaac3ff50 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Fri, 30 Oct 2026 15:47:09 -0400 Subject: [PATCH 01/34] taskdoor: a door paired computers reach each other's tasks through A paired computer that serves now registers a task door under a name derived from the identity and its device id, so another paired computer can dial it without anything being published. Inside the existing Noise IK tunnel (with a prologue of its own) each end proves its device with a certificate signed over the channel binding, and a revoked computer is turned away by the target. The wire carries start, watch, stop and ack for one attempt; the target keeps each attempt in an atomic store whose admission is written before the executor runs, so a repeated start or a restart never runs a task twice. The executor is an interface here; the next commits supply the real one. Co-Authored-By: Claude Opus 5.5 --- internal/pair/registration.go | 139 +++++++++ internal/pair/serve.go | 88 +----- internal/pair/task_tunnel_test.go | 111 +++++++ internal/pair/tunnel.go | 59 +++- internal/taskdoor/attempts.go | 385 +++++++++++++++++++++++++ internal/taskdoor/clock.go | 35 +++ internal/taskdoor/control_test.go | 224 +++++++++++++++ internal/taskdoor/dial.go | 212 ++++++++++++++ internal/taskdoor/door_test.go | 431 ++++++++++++++++++++++++++++ internal/taskdoor/frames.go | 234 +++++++++++++++ internal/taskdoor/frames_test.go | 48 ++++ internal/taskdoor/hello.go | 128 +++++++++ internal/taskdoor/keepalive.go | 93 ++++++ internal/taskdoor/keepalive_test.go | 278 ++++++++++++++++++ internal/taskdoor/keys.go | 34 +++ internal/taskdoor/keys_test.go | 40 +++ internal/taskdoor/security_test.go | 424 +++++++++++++++++++++++++++ internal/taskdoor/serve.go | 328 +++++++++++++++++++++ 18 files changed, 3202 insertions(+), 89 deletions(-) create mode 100644 internal/pair/registration.go create mode 100644 internal/pair/task_tunnel_test.go create mode 100644 internal/taskdoor/attempts.go create mode 100644 internal/taskdoor/clock.go create mode 100644 internal/taskdoor/control_test.go create mode 100644 internal/taskdoor/dial.go create mode 100644 internal/taskdoor/door_test.go create mode 100644 internal/taskdoor/frames.go create mode 100644 internal/taskdoor/frames_test.go create mode 100644 internal/taskdoor/hello.go create mode 100644 internal/taskdoor/keepalive.go create mode 100644 internal/taskdoor/keepalive_test.go create mode 100644 internal/taskdoor/keys.go create mode 100644 internal/taskdoor/keys_test.go create mode 100644 internal/taskdoor/security_test.go create mode 100644 internal/taskdoor/serve.go diff --git a/internal/pair/registration.go b/internal/pair/registration.go new file mode 100644 index 0000000000..d5f8caad49 --- /dev/null +++ b/internal/pair/registration.go @@ -0,0 +1,139 @@ +package pair + +import ( + "context" + "crypto/ecdh" + "errors" + "fmt" + "time" + + "github.com/Agent-Field/codeaf/internal/relay" +) + +// RegistrationLoop keeps a key-derived relay door registered. Both machine +// and task doors share outage handling so a reconnect never changes what +// codeaf serve tells a person about sync. +type RegistrationLoop struct { + Service string + Key *ecdh.PrivateKey + Say func(string) + Now func() time.Time + Wait func(context.Context, time.Duration) error + Ready func(*relay.Registration) + Hold func(context.Context, *relay.Registration) +} + +func (r *RegistrationLoop) say(line string) { + if r.Say != nil { + r.Say(line) + } +} +func (r *RegistrationLoop) now() time.Time { + if r.Now != nil { + return r.Now() + } + return time.Now() +} +func (r *RegistrationLoop) wait(ctx context.Context, d time.Duration) error { + if r.Wait != nil { + return r.Wait(ctx, d) + } + timer := time.NewTimer(d) + defer timer.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return nil + } +} + +// Run returns only on cancellation or a refusal that retrying cannot fix. +// Hold owns accepted streams until the registration ends. +func (r *RegistrationLoop) Run(ctx context.Context) error { + if r.Key == nil || r.Hold == nil { + return errors.New("pair: a registration needs a key and a stream handler") + } + wait := time.Second + announced := false + // told and throttled are each said once per spell, not once per retry. + // told is set when the person has been told sync was lost and not yet + // that it came back, so the two lines always arrive as a pair. + told := false + throttled := false + tellLost := func() { + if !told { + r.say(LostSyncLine) + } + told = true + } + for { + registration, err := relay.Register(ctx, r.Service, r.Key) + switch { + case err == nil: + case errors.Is(err, relay.ErrTooMany): + if !throttled { + r.say(TooManyRegistrationsLine) + } + throttled = true + case errors.Is(err, relay.ErrNameTaken) && announced: + // This machine held the name a moment ago, so what holds it now is + // its own dead connection, which the relay forgets when it times + // out. That is the outage still going on, and is waited out. + tellLost() + case errors.Is(err, relay.ErrNameTaken): + // RULE THREE OF THE NAME POLICY, MET FROM THIS SIDE. Something is + // already connected under this machine's name, which in practice + // means a second `codeaf serve` on this same machine. Trying again + // forever would be two processes fighting over one name. + return fmt.Errorf("this machine is already connected to sync as %s — there is only one of it, so close the other `codeaf serve`", relay.NameFor(r.Key.PublicKey().Bytes())) + case errors.Is(err, relay.ErrUnreachable): + if !announced { + return Unreachable(r.Service) + } + tellLost() + case errors.Is(err, relay.ErrNoDoor) && announced: + // The door was there a moment ago, so this is a relay being + // redeployed, and is waited out like any other outage. + tellLost() + case errors.Is(err, relay.ErrNoDoor): + // Trying again cannot add a door the service does not have. + return NoDoor(r.Service) + default: + if !announced { + return err + } + r.say("sync refused this machine: " + err.Error()) + } + + if err == nil { + announced = true + // A LOST LINE IS ANSWERED BY A BACK LINE. The banner is reprinted only + // when the name or code changed, so without this the last thing on + // the screen of a machine that recovered would still say it was lost. + if told { + r.say(BackLine(registration.Name())) + told = false + } + if r.Ready != nil { + r.Ready(registration) + } + since := r.now() + r.Hold(ctx, registration) + // Backoff resets only after the carrier stayed up for a meaningful + // stretch; an upgrade that drops right after Register keeps + // backing off instead of hammering the relay every second. + if r.now().Sub(since) >= steadyAfter { + wait = time.Second + throttled = false + } + } + + if err := r.wait(ctx, wait); err != nil { + return nil + } + if wait *= 2; wait > time.Minute { + wait = time.Minute + } + } +} diff --git a/internal/pair/serve.go b/internal/pair/serve.go index 56fed9b108..cdc1b1b414 100644 --- a/internal/pair/serve.go +++ b/internal/pair/serve.go @@ -145,90 +145,14 @@ func (h *Host) Run(ctx context.Context) error { if h.Desk != nil && h.Desk.Now == nil && h.Now != nil { h.Desk.Now = h.Now } - wait := time.Second - announced := false - // told and throttled are each said once per spell, not once per retry. - // told is set when the person has been told sync was lost and not yet - // that it came back, so the two lines always arrive as a pair. - told := false - throttled := false - tellLost := func() { - if !told { - h.say(LostSyncLine) - } - told = true - } - for { - registration, err := relay.Register(ctx, h.Service, h.Device.Private()) - switch { - case err == nil: - case errors.Is(err, relay.ErrTooMany): - if !throttled { - h.say(TooManyRegistrationsLine) - } - throttled = true - case errors.Is(err, relay.ErrNameTaken) && announced: - // This machine held the name a moment ago, so what holds it now is - // its own dead connection, which the relay forgets when it times - // out. That is the outage still going on, and is waited out. - tellLost() - case errors.Is(err, relay.ErrNameTaken): - // RULE THREE OF THE NAME POLICY, MET FROM THIS SIDE. Something is - // already connected under this machine's name, which in practice - // means a second `codeaf serve` on this same machine. Trying again - // forever would be two processes fighting over one name. - return fmt.Errorf("this machine is already connected to sync as %s — there is only one of it, so close the other `codeaf serve`", h.Device.Name()) - case errors.Is(err, relay.ErrUnreachable): - if !announced { - return Unreachable(h.Service) - } - tellLost() - case errors.Is(err, relay.ErrNoDoor) && announced: - // The door was there a moment ago, so this is a relay being - // redeployed, and is waited out like any other outage. - tellLost() - case errors.Is(err, relay.ErrNoDoor): - // Trying again cannot add a door the service does not have. - return NoDoor(h.Service) - default: - if !announced { - return err - } - h.say("sync refused this machine: " + err.Error()) - } - - if err == nil { - announced = true - // A LOST LINE IS ANSWERED BY A BACK LINE. The banner is reprinted only - // when the name or code changed, so without this the last thing on - // the screen of a machine that recovered would still say it was lost. - if told { - h.say(BackLine(registration.Name())) - told = false - } + return (&RegistrationLoop{ + Service: h.Service, Key: h.Device.Private(), Say: h.Say, + Now: h.now, Wait: h.wait, Hold: h.hold, + Ready: func(registration *relay.Registration) { code, codeErr := h.Desk.Offer() - // Reprints happen only when the name or pairing code actually - // changed, so a brief network blip neither spams the screen nor - // disguises a flapping carrier as a fresh start. h.sayBanner(h.banner(registration.Name(), code, codeErr), false) - since := h.now() - h.hold(ctx, registration) - // Backoff resets only after the carrier stayed up for a meaningful - // stretch; an upgrade that drops right after Register keeps - // backing off instead of hammering the relay every second. - if h.now().Sub(since) >= steadyAfter { - wait = time.Second - throttled = false - } - } - - if err := h.wait(ctx, wait); err != nil { - return nil - } - if wait *= 2; wait > time.Minute { - wait = time.Minute - } - } + }, + }).Run(ctx) } // hold answers arrivals until the registration ends, and returns only after diff --git a/internal/pair/task_tunnel_test.go b/internal/pair/task_tunnel_test.go new file mode 100644 index 0000000000..d87a09d582 --- /dev/null +++ b/internal/pair/task_tunnel_test.go @@ -0,0 +1,111 @@ +package pair + +import ( + "bytes" + "crypto/ecdh" + "crypto/rand" + "io" + "net" + "testing" + "time" +) + +// Contract 14 depends on an encrypted task channel whose binding both devices +// can sign; the separate prologue prevents a machine door accepting a task. +func TestTaskTunnelBindsBothEndsAndSeparatesMachineDoors(t *testing.T) { + key := func() *ecdh.PrivateKey { + k, err := ecdh.X25519().GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + return k + } + a, b := key(), key() + t.Run("binding and plaintext", func(t *testing.T) { + near, far := net.Pipe() + defer near.Close() + defer far.Close() + _ = near.SetDeadline(time.Now().Add(3 * time.Second)) + _ = far.SetDeadline(time.Now().Add(3 * time.Second)) + result := make(chan *Tunnel, 1) + failed := make(chan error, 1) + go func() { + tunnel, err := AcceptWithKey(far, b) + if err != nil { + failed <- err + return + } + result <- tunnel + _, err = tunnel.Write([]byte("only the endpoints can read this")) + failed <- err + }() + client, err := ConnectWithKey(near, b.PublicKey().Bytes(), a) + if err != nil { + t.Fatal(err) + } + var server *Tunnel + select { + case server = <-result: + case err := <-failed: + t.Fatal(err) + } + binding := client.ChannelBinding() + if len(binding) == 0 || !bytes.Equal(binding, server.ChannelBinding()) { + t.Fatal("ends do not share the completed handshake binding") + } + binding[0] ^= 1 + if bytes.Equal(binding, client.ChannelBinding()) { + t.Fatal("a caller could mutate the binding") + } + if !bytes.Equal(server.Peer(), a.PublicKey().Bytes()) { + t.Fatal("server did not authenticate the initiator static") + } + data := make([]byte, len("only the endpoints can read this")) + if _, err := io.ReadFull(client, data); err != nil { + t.Fatal(err) + } + if string(data) != "only the endpoints can read this" { + t.Fatalf("plaintext = %q", data) + } + if err := <-failed; err != nil { + t.Fatal(err) + } + }) + t.Run("task cannot enter a machine door", func(t *testing.T) { + near, far := net.Pipe() + defer near.Close() + defer far.Close() + _ = near.SetDeadline(time.Now().Add(3 * time.Second)) + _ = far.SetDeadline(time.Now().Add(3 * time.Second)) + machine := Device{private: b} + finished := make(chan error, 1) + go func() { + defer far.Close() + var intent [1]byte + _, _ = io.ReadFull(far, intent[:]) + _, _, err := acceptAsMachine(far, machine, func([]byte, string) error { t.Error("task reached machine authorization"); return nil }) + finished <- err + }() + if _, err := ConnectWithKey(near, b.PublicKey().Bytes(), a); err == nil { + t.Fatal("task handshake completed against a machine door") + } + if err := <-finished; err == nil { + t.Fatal("machine accepted task handshake") + } + }) + t.Run("machine cannot enter a task door", func(t *testing.T) { + near, far := net.Pipe() + defer near.Close() + defer far.Close() + _ = near.SetDeadline(time.Now().Add(3 * time.Second)) + _ = far.SetDeadline(time.Now().Add(3 * time.Second)) + finished := make(chan error, 1) + go func() { defer far.Close(); _, err := AcceptWithKey(far, b); finished <- err }() + if _, err := connectAsSurface(near, b.PublicKey().Bytes(), Device{private: a}, "laptop"); err == nil { + t.Fatal("machine handshake completed against a task door") + } + if err := <-finished; err == nil { + t.Fatal("task door accepted machine handshake") + } + }) +} diff --git a/internal/pair/tunnel.go b/internal/pair/tunnel.go index 311cd7f43c..9708c19281 100644 --- a/internal/pair/tunnel.go +++ b/internal/pair/tunnel.go @@ -28,6 +28,7 @@ package pair // cannot see a word of it. import ( + "crypto/ecdh" "errors" "io" "sync" @@ -41,7 +42,8 @@ type Tunnel struct { conn io.ReadWriteCloser // peer is the other end's long-term public key, as the completed handshake // proved it — not as anybody claimed it. - peer []byte + peer []byte + binding []byte sendMu sync.Mutex send *noise.CipherState @@ -56,6 +58,41 @@ type Tunnel struct { // Peer is the key on the other end, proved by the handshake. func (t *Tunnel) Peer() []byte { return t.peer } +// ChannelBinding is the completed handshake hash. Returning a copy lets a +// higher protocol sign it without allowing a caller to change this tunnel's +// identity proof. +func (t *Tunnel) ChannelBinding() []byte { return append([]byte(nil), t.binding...) } + +// taskTunnelPrologue separates identity task doors from machine-access doors, +// even if a caller accidentally supplies the same static key to both. +const taskTunnelPrologue = "codeaf task door v1 connect" + +// ConnectWithKey opens an identity task tunnel with an explicit static key. +// The responder's key must already be pinned by the caller. +func ConnectWithKey(conn io.ReadWriteCloser, peer []byte, key *ecdh.PrivateKey) (*Tunnel, error) { + if key == nil { + return nil, errors.New("pair: a task tunnel needs a static key") + } + return connectWithStatic(conn, peer, Device{private: key}.noiseKey(), "", taskTunnelPrologue) +} + +// AcceptWithKey answers an identity task tunnel. Device authorization follows +// inside the tunnel, where certificates can be bound to ChannelBinding. +func AcceptWithKey(conn io.ReadWriteCloser, key *ecdh.PrivateKey) (*Tunnel, error) { + if key == nil { + return nil, errors.New("pair: a task tunnel needs a static key") + } + var intent [1]byte + if _, err := io.ReadFull(conn, intent[:]); err != nil { + return nil, err + } + if intent[0] != intentConnect { + return nil, errors.New("this door takes task connections") + } + tunnel, _, err := acceptWithStatic(conn, Device{private: key}.noiseKey(), func([]byte, string) error { return nil }, taskTunnelPrologue) + return tunnel, err +} + // Read hands back plaintext. A record arrives whole and is drained across as // many Reads as the caller needs, which is what makes this an ordinary stream // rather than a message queue. @@ -115,6 +152,10 @@ func (t *Tunnel) Close() error { // connectAsSurface dials a machine whose key this device already pinned. func connectAsSurface(conn io.ReadWriteCloser, machineKey []byte, me Device, label string) (*Tunnel, error) { + return connectWithStatic(conn, machineKey, me.noiseKey(), label, protocol+" connect") +} + +func connectWithStatic(conn io.ReadWriteCloser, machineKey []byte, static noise.DHKey, label, prologue string) (*Tunnel, error) { if _, err := conn.Write([]byte{intentConnect}); err != nil { return nil, err } @@ -122,8 +163,8 @@ func connectAsSurface(conn io.ReadWriteCloser, machineKey []byte, me Device, lab CipherSuite: suite, Pattern: noise.HandshakeIK, Initiator: true, - Prologue: []byte(protocol + " connect"), - StaticKeypair: me.noiseKey(), + Prologue: []byte(prologue), + StaticKeypair: static, PeerStatic: machineKey, }) if err != nil { @@ -159,7 +200,7 @@ func connectAsSurface(conn io.ReadWriteCloser, machineKey []byte, me Device, lab if err := hearVerdict(said); err != nil { return nil, err } - return &Tunnel{conn: conn, peer: machineKey, send: first, recv: second}, nil + return &Tunnel{conn: conn, peer: machineKey, binding: append([]byte(nil), handshake.ChannelBinding()...), send: first, recv: second}, nil } // acceptAsMachine answers a connection, and asks `allow` whether the device on @@ -169,12 +210,16 @@ func connectAsSurface(conn io.ReadWriteCloser, machineKey []byte, me Device, lab // gets a sentence saying so, and it gets it from the machine itself — the relay // neither writes it nor reads it. func acceptAsMachine(conn io.ReadWriteCloser, me Device, allow func(key []byte, label string) error) (*Tunnel, []byte, error) { + return acceptWithStatic(conn, me.noiseKey(), allow, protocol+" connect") +} + +func acceptWithStatic(conn io.ReadWriteCloser, static noise.DHKey, allow func(key []byte, label string) error, prologue string) (*Tunnel, []byte, error) { handshake, err := noise.NewHandshakeState(noise.Config{ CipherSuite: suite, Pattern: noise.HandshakeIK, Initiator: false, - Prologue: []byte(protocol + " connect"), - StaticKeypair: me.noiseKey(), + Prologue: []byte(prologue), + StaticKeypair: static, }) if err != nil { return nil, nil, err @@ -204,7 +249,7 @@ func acceptAsMachine(conn io.ReadWriteCloser, me Device, allow func(key []byte, } // The responder writes with the second cipher state and reads with the // first, which is Noise's own ordering: the first is the initiator's. - return &Tunnel{conn: conn, peer: deviceKey, send: second, recv: first}, deviceKey, nil + return &Tunnel{conn: conn, peer: deviceKey, binding: append([]byte(nil), handshake.ChannelBinding()...), send: second, recv: first}, deviceKey, nil } // withDeadline puts a bound on a handshake when the stream underneath carries diff --git a/internal/taskdoor/attempts.go b/internal/taskdoor/attempts.go new file mode 100644 index 0000000000..5cc49883f8 --- /dev/null +++ b/internal/taskdoor/attempts.go @@ -0,0 +1,385 @@ +package taskdoor + +import ( + "bufio" + "bytes" + "context" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "sync" + "time" + + "github.com/Agent-Field/codeaf/internal/fsreplace" +) + +// MaxRunning is shared by admission and the sentence describing its limit. +const MaxRunning = 2 + +// Executor owns a run until it finishes or Serve's context ends. Ending that +// context must leave a surviving child's running state available for Adopt. +// Neither a lost watch nor an origin disconnect cancels a run. +type Executor interface { + Run(context.Context, *Attempt, Journal) error + Stop(attemptID string) error + Adopt(context.Context, *Attempt, Journal) error +} + +// Journal saves a step, spend, phase or finish before waking any watchers. +// Executors do not choose sequence numbers; one journal orders all events. +type Journal interface{ Append(event any) error } + +type State struct { + Phase string `json:"phase"` + PID int `json:"pid,omitempty"` + StartedAt time.Time `json:"started_at,omitzero"` + FinishedAt time.Time `json:"finished_at,omitzero"` +} + +// Attempt is an admitted immutable request and its private working folder. +// Child metadata goes through UpdateState so adoption cannot see half a write. +type Attempt struct { + ID string + Dir string + Request Start + mu sync.Mutex + state State + changed chan struct{} + removed bool + clock Clock +} + +func (a *Attempt) State() State { a.mu.Lock(); defer a.mu.Unlock(); return a.state } +func (a *Attempt) UpdateState(update func(*State)) error { + a.mu.Lock() + defer a.mu.Unlock() + if a.removed { + return os.ErrNotExist + } + next := a.state + update(&next) + if err := atomicJSON(filepath.Join(a.Dir, "state.json"), next); err != nil { + return err + } + a.state = next + return nil +} +func (a *Attempt) notify() { close(a.changed); a.changed = make(chan struct{}) } + +type attemptStore struct { + root string + clock Clock + mu sync.Mutex + attempts map[string]*Attempt +} + +func validAttempt(id string) bool { + if len(id) != len("att_")+16 || id[:4] != "att_" { + return false + } + _, err := hex.DecodeString(id[4:]) + return err == nil +} + +func openAttempts(root string, clock Clock) (*attemptStore, error) { + if root == "" { + return nil, errors.New("taskdoor: an attempt store folder is required") + } + if err := os.MkdirAll(root, 0o700); err != nil { + return nil, err + } + entries, err := os.ReadDir(root) + if err != nil { + return nil, err + } + store := &attemptStore{root: root, clock: clock, attempts: map[string]*Attempt{}} + for _, entry := range entries { + if !validAttempt(entry.Name()) { + continue + } + if !entry.IsDir() { + return nil, fmt.Errorf("taskdoor: attempt %s is not a folder", entry.Name()) + } + a := &Attempt{ID: entry.Name(), Dir: filepath.Join(root, entry.Name()), changed: make(chan struct{}), clock: clock} + if err := readJSON(filepath.Join(a.Dir, "request.json"), &a.Request); err != nil { + return nil, err + } + if err := readJSON(filepath.Join(a.Dir, "state.json"), &a.state); err != nil { + return nil, err + } + events, err := readEvents(a.Dir) + if err != nil { + return nil, err + } + // A crash after the journal's rename but before its derived state write + // must not hand a completed child to Adopt or admit another execution. + if len(events) > 0 && events[len(events)-1].Type == "finished" { + var end Finished + if err := events[len(events)-1].Decode(&end); err != nil { + return nil, err + } + if err := a.saveResult(end); err != nil { + return nil, err + } + } + store.attempts[a.ID] = a + } + return store, nil +} +func (s *attemptStore) running() []*Attempt { + s.mu.Lock() + defer s.mu.Unlock() + var out []*Attempt + for _, a := range s.attempts { + if a.State().Phase == "running" { + out = append(out, a) + } + } + return out +} +func (s *attemptStore) get(id string) *Attempt { + s.mu.Lock() + defer s.mu.Unlock() + a := s.attempts[id] + if a == nil { + return nil + } + a.mu.Lock() + defer a.mu.Unlock() + if a.removed { + return nil + } + return a +} + +// admit publishes the complete admission directory before returning permission +// to call Run. A duplicate wins over the concurrency check, even when busy. +func (s *attemptStore) admit(id string, request Start, busy string) (*Attempt, bool, error) { + if !validAttempt(id) { + return nil, false, errors.New("that task attempt id is not readable") + } + s.mu.Lock() + defer s.mu.Unlock() + if a := s.attempts[id]; a != nil { + a.mu.Lock() + removed := a.removed + a.mu.Unlock() + if removed { + return nil, false, errors.New("that task is still stopping") + } + return a, false, nil + } + running := 0 + for _, a := range s.attempts { + if a.State().Phase == "running" { + running++ + } + } + if running >= MaxRunning { + return nil, false, errors.New(busy) + } + a := &Attempt{ID: id, Dir: filepath.Join(s.root, id), Request: request, state: State{Phase: "running", StartedAt: s.clock.Now()}, changed: make(chan struct{}), clock: s.clock} + staging, err := os.MkdirTemp(s.root, ".admit-*") + if err != nil { + return nil, false, err + } + defer os.RemoveAll(staging) + if err := atomicJSON(filepath.Join(staging, "request.json"), request); err != nil { + return nil, false, err + } + if err := atomicJSON(filepath.Join(staging, "state.json"), a.state); err != nil { + return nil, false, err + } + if err := atomicWrite(filepath.Join(staging, "events.jsonl"), nil); err != nil { + return nil, false, err + } + if err := os.Rename(staging, a.Dir); err != nil { + return nil, false, err + } + s.attempts[id] = a + return a, true, nil +} + +// forget does not let a late executor recreate an acknowledged directory. +// A still-stopping executor retains its admission slot until it returns. +func (s *attemptStore) forget(id string) error { + s.mu.Lock() + defer s.mu.Unlock() + a := s.attempts[id] + if a == nil { + return nil + } + a.mu.Lock() + defer a.mu.Unlock() + if err := os.RemoveAll(a.Dir); err != nil { + return err + } + a.removed = true + a.notify() + if a.state.Phase != "running" { + delete(s.attempts, id) + } + return nil +} +func (s *attemptStore) released(a *Attempt) { + s.mu.Lock() + defer s.mu.Unlock() + a.mu.Lock() + defer a.mu.Unlock() + if a.removed { + delete(s.attempts, a.ID) + } +} + +type attemptJournal struct{ a *Attempt } + +func (j attemptJournal) Append(event any) error { + a := j.a + kind := payloadType(event) + switch kind { + case "step", "spend", "phase", "finished": + default: + return errors.New("taskdoor: only task events belong in a journal") + } + if end, ok := event.(Finished); ok { + switch end.Outcome { + case "done", "stopped", "incomplete", "interrupted": + default: + return errors.New("taskdoor: unknown task outcome") + } + } + a.mu.Lock() + defer a.mu.Unlock() + if a.removed { + return os.ErrNotExist + } + if a.state.Phase != "running" { + return errors.New("taskdoor: that task has already finished") + } + events, err := readEvents(a.Dir) + if err != nil { + return err + } + if len(events) > 0 && events[len(events)-1].Type == "finished" { + return errors.New("taskdoor: that task has already finished") + } + frame, err := makeFrame(a.ID, uint64(len(events)+1), event) + if err != nil { + return err + } + raw, err := os.ReadFile(filepath.Join(a.Dir, "events.jsonl")) + if err != nil { + return err + } + raw = append(raw, frame.Payload...) + raw = append(raw, '\n') + // Replacing the JSONL file, rather than appending in place, makes a power + // loss leave either the previous journal or the whole new event. + if err := atomicWrite(filepath.Join(a.Dir, "events.jsonl"), raw); err != nil { + return err + } + if end, ok := event.(Finished); ok { + err = a.saveResult(end) + } + // The event is committed even if a derived result write failed. Watchers + // still receive it, and reopen repairs result.json from the journal. + a.notify() + return err +} +func (a *Attempt) saveResult(end Finished) error { + if err := atomicJSON(filepath.Join(a.Dir, "result.json"), end); err != nil { + return err + } + next := a.state + next.Phase = end.Outcome + if next.FinishedAt.IsZero() { + next.FinishedAt = a.clock.Now() + } + if err := atomicJSON(filepath.Join(a.Dir, "state.json"), next); err != nil { + return err + } + a.state = next + return nil +} + +// after takes the replay and the next wakeup under the same lock, so an event +// written as a watch changes from replay to live can neither vanish nor repeat. +func (a *Attempt) after(from uint64) ([]Frame, <-chan struct{}, bool, error) { + a.mu.Lock() + defer a.mu.Unlock() + if a.removed { + return nil, nil, true, os.ErrNotExist + } + events, err := readEvents(a.Dir) + if err != nil { + return nil, nil, false, err + } + finished := len(events) > 0 && events[len(events)-1].Type == "finished" + first := len(events) + for i, event := range events { + if event.Seq > from { + first = i + break + } + } + return events[first:], a.changed, finished, nil +} +func readEvents(dir string) ([]Frame, error) { + file, err := os.Open(filepath.Join(dir, "events.jsonl")) + if err != nil { + return nil, err + } + defer file.Close() + scanner := bufio.NewScanner(file) + scanner.Buffer(make([]byte, 4096), MaxFrameBytes+1) + var events []Frame + for scanner.Scan() { + raw := bytes.Clone(scanner.Bytes()) + if len(raw) > MaxFrameBytes { + return nil, frameTooLarge() + } + event, err := parseFrame(raw) + if err != nil { + return nil, err + } + if event.Seq != uint64(len(events)+1) { + return nil, errors.New("taskdoor: the saved task events are out of order") + } + if len(events) > 0 && events[len(events)-1].Type == "finished" { + return nil, errors.New("taskdoor: saved events follow the task's finish") + } + events = append(events, event) + } + return events, scanner.Err() +} +func readJSON(path string, into any) error { + raw, err := os.ReadFile(path) + if err != nil { + return err + } + return json.Unmarshal(raw, into) +} +func atomicJSON(path string, value any) error { + raw, err := json.Marshal(value) + if err != nil { + return err + } + return atomicWrite(path, raw) +} +func atomicWrite(path string, raw []byte) error { + file, err := os.CreateTemp(filepath.Dir(path), ".write-*") + if err != nil { + return err + } + defer os.Remove(file.Name()) + _, writeErr := file.Write(raw) + syncErr := file.Sync() + closeErr := file.Close() + if err := errors.Join(writeErr, syncErr, closeErr); err != nil { + return err + } + return fsreplace.Rename(file.Name(), path) +} diff --git a/internal/taskdoor/clock.go b/internal/taskdoor/clock.go new file mode 100644 index 0000000000..80ec8c5322 --- /dev/null +++ b/internal/taskdoor/clock.go @@ -0,0 +1,35 @@ +package taskdoor + +import "time" + +const ( + PingEvery = 15 * time.Second + SilentAfter = 3 * PingEvery + RosterFor = time.Minute + DialWithin = 10 * time.Second +) + +// Clock supplies both time readings and wakeups: swapping only Now would +// leave a silent-watch test waiting for the wall clock's ticker. +type Clock interface { + Now() time.Time + NewTicker(time.Duration) Ticker +} +type Ticker interface { + C() <-chan time.Time + Stop() +} +type wallClock struct{} + +func (wallClock) Now() time.Time { return time.Now() } +func (wallClock) NewTicker(d time.Duration) Ticker { return wallTicker{time.NewTicker(d)} } + +type wallTicker struct{ *time.Ticker } + +func (t wallTicker) C() <-chan time.Time { return t.Ticker.C } +func clockOrWall(clock Clock) Clock { + if clock != nil { + return clock + } + return wallClock{} +} diff --git a/internal/taskdoor/control_test.go b/internal/taskdoor/control_test.go new file mode 100644 index 0000000000..cbc5d529ee --- /dev/null +++ b/internal/taskdoor/control_test.go @@ -0,0 +1,224 @@ +package taskdoor_test + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "sync" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/taskdoor" +) + +// Contract 12 requires the original request and running admission to be on +// disk before any executor action, even if the origin never reads started. +func TestAdmissionIsSavedBeforeTheExecutorAndSurvivesALostReply(t *testing.T) { + r := newRig(t) + entered := make(chan struct{}) + problem := make(chan error, 1) + r.executor.run = func(ctx context.Context, a *taskdoor.Attempt, j taskdoor.Journal) error { + var saved taskdoor.Start + raw, err := os.ReadFile(filepath.Join(a.Dir, "request.json")) + if err == nil { + err = json.Unmarshal(raw, &saved) + } + if err != nil { + problem <- err + return err + } + if saved.Instruction != request().Instruction || saved.Input.Head != request().Input.Head { + t.Error("executor saw a different saved request") + } + var state taskdoor.State + raw, err = os.ReadFile(filepath.Join(a.Dir, "state.json")) + if err == nil { + err = json.Unmarshal(raw, &state) + } + if err != nil { + problem <- err + return err + } + if state.Phase != "running" || state.StartedAt.IsZero() { + t.Error("executor ran before admission") + } + close(entered) + <-ctx.Done() + return ctx.Err() + } + r.serve(r.config()) + c := r.dial() + id := attempt(30) + send(t, c, id, request()) + _ = c.Close() + select { + case <-entered: + case err := <-problem: + t.Fatal(err) + case <-time.After(5 * time.Second): + t.Fatal("lost start reply lost the admission") + } + c = r.dial() + send(t, c, id, request()) + receive(t, c, "started") + if r.executor.calls.Load() != 1 { + t.Fatal("retry after a lost reply ran twice") + } +} + +// DESIGN section 4's stop road, supporting contract 9 recovery, returns the +// stopping receipt and then journals the +// executor's stopped result for every reconnecting watcher. +func TestAStopAsksTheExecutorAndItsResultCanBeWatched(t *testing.T) { + r := newRig(t) + r.executor.stops = make(chan string, 1) + stopped := make(chan struct{}) + r.executor.run = func(ctx context.Context, a *taskdoor.Attempt, j taskdoor.Journal) error { + select { + case <-stopped: + return j.Append(taskdoor.Finished{Outcome: "stopped", Report: "stopped by you"}) + case <-ctx.Done(): + return ctx.Err() + } + } + r.serve(r.config()) + c := r.dial() + id := attempt(31) + send(t, c, id, request()) + receive(t, c, "started") + send(t, c, id, taskdoor.Stop{}) + receive(t, c, "stopping") + select { + case got := <-r.executor.stops: + if got != id { + t.Fatalf("stopped %s", got) + } + case <-time.After(5 * time.Second): + t.Fatal("executor did not receive stop") + } + close(stopped) + send(t, c, id, taskdoor.Watch{}) + var result taskdoor.Finished + decode(t, receive(t, c, "finished"), &result) + if result.Outcome != "stopped" { + t.Fatalf("stop result = %+v", result) + } +} + +// Contracts 11 and 12: abandoning a running attempt erases it and prevents a +// late journal write from recreating its files. +func TestAnAbandonedRunningAttemptCannotRecreateItsJournal(t *testing.T) { + r := newRig(t) + r.executor.stops = make(chan string, 1) + late := make(chan struct{}) + written := make(chan error, 1) + entered := make(chan struct{}) + r.executor.run = func(ctx context.Context, a *taskdoor.Attempt, j taskdoor.Journal) error { + close(entered) + select { + case <-late: + err := j.Append(taskdoor.Finished{Outcome: "done"}) + written <- err + return err + case <-ctx.Done(): + return ctx.Err() + } + } + r.serve(r.config()) + c := r.dial() + id := attempt(32) + send(t, c, id, request()) + receive(t, c, "started") + <-entered + send(t, c, id, taskdoor.Ack{Disposition: "abandoned"}) + receive(t, c, "acked") + close(late) + if err := <-written; !os.IsNotExist(err) { + t.Fatalf("late write = %v", err) + } + if _, err := os.Stat(filepath.Join(r.root, id)); !os.IsNotExist(err) { + t.Fatalf("late result recreated its folder: %v", err) + } +} + +// Contract 12 includes simultaneous retries arriving on different streams. +func TestConcurrentStartsForOneAttemptRunOnce(t *testing.T) { + r := newRig(t) + r.executor.run = func(ctx context.Context, _ *taskdoor.Attempt, _ taskdoor.Journal) error { + <-ctx.Done() + return ctx.Err() + } + r.serve(r.config()) + a, b := r.dial(), r.dial() + id := attempt(33) + var group sync.WaitGroup + for _, c := range []*taskdoor.Conn{a, b} { + group.Add(1) + go func() { defer group.Done(); send(t, c, id, request()); receive(t, c, "started") }() + } + group.Wait() + // The executor is asynchronous; a stop receipt is a connection-level fence, + // while this channel-free wait only waits for the single admission to run. + deadline := time.Now().Add(5 * time.Second) + for r.executor.calls.Load() == 0 && time.Now().Before(deadline) { + time.Sleep(time.Millisecond) + } + if r.executor.calls.Load() != 1 { + t.Fatalf("concurrent retries ran %d times", r.executor.calls.Load()) + } +} + +// Contract 12's never-rerun rule survives a crash after a committed finished +// event but before the corresponding result and state writes. +func TestACommittedFinishIsRecoveredWithoutAdoptingOrRerunning(t *testing.T) { + r := newRig(t) + stop := r.serve(r.config()) + c := r.dial() + id := attempt(34) + send(t, c, id, request()) + receive(t, c, "started") + send(t, c, id, taskdoor.Watch{}) + receive(t, c, "finished") + _ = c.Close() + stop() + path := filepath.Join(r.root, id, "state.json") + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var state taskdoor.State + if err := json.Unmarshal(raw, &state); err != nil { + t.Fatal(err) + } + state.Phase = "running" + state.FinishedAt = time.Time{} + raw, err = json.Marshal(state) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, raw, 0o600); err != nil { + t.Fatal(err) + } + if err := os.Remove(filepath.Join(r.root, id, "result.json")); err != nil { + t.Fatal(err) + } + r.serve(r.config()) + c = r.dial() + send(t, c, id, request()) + var started taskdoor.Started + decode(t, receive(t, c, "started"), &started) + if started.State.Phase != "done" { + t.Fatalf("committed finish recovered as %s", started.State.Phase) + } + send(t, c, id, taskdoor.Watch{}) + if frame := receive(t, c, "finished"); frame.Seq != 1 { + t.Fatalf("recovered finish seq = %d", frame.Seq) + } + if _, err := os.Stat(filepath.Join(r.root, id, "result.json")); err != nil { + t.Fatal("result was not repaired:", err) + } + if r.executor.calls.Load() != 1 || r.executor.adopts.Load() != 0 { + t.Fatalf("completed journal ran/adopted %d/%d", r.executor.calls.Load(), r.executor.adopts.Load()) + } +} diff --git a/internal/taskdoor/dial.go b/internal/taskdoor/dial.go new file mode 100644 index 0000000000..5eb59c0268 --- /dev/null +++ b/internal/taskdoor/dial.go @@ -0,0 +1,212 @@ +package taskdoor + +import ( + "context" + "errors" + "io" + "sync" + "sync/atomic" + "time" + + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/pair" + "github.com/Agent-Field/codeaf/internal/relay" +) + +// Dial reaches the chosen device within one bound covering relay, Noise and +// hello. The returned connection lives until it or the caller's context closes. +func Dial(ctx context.Context, service string, id identity.Identity, dev identity.Dev, targetDeviceID, targetName string) (*Conn, error) { + return dial(ctx, service, id, dev, targetDeviceID, targetName, wallClock{}) +} + +// DialWithClock supplies watch wakeups as well as time readings for callers +// that need a deterministic clock. Dial uses the wall clock. +func DialWithClock(ctx context.Context, service string, id identity.Identity, dev identity.Dev, targetDeviceID, targetName string, clock Clock) (*Conn, error) { + return dial(ctx, service, id, dev, targetDeviceID, targetName, clockOrWall(clock)) +} +func dial(ctx context.Context, service string, id identity.Identity, dev identity.Dev, targetDeviceID, targetName string, clock Clock) (*Conn, error) { + timeout, cancel := context.WithTimeout(ctx, DialWithin) + defer cancel() + key, err := DoorKey(id, dev.ID()) + if err != nil { + return nil, err + } + target, err := DoorKey(id, targetDeviceID) + if err != nil { + return nil, err + } + stream, err := relay.Dial(timeout, service, relay.NameFor(target.PublicKey().Bytes())) + if err != nil { + if ctx.Err() != nil { + return nil, ctx.Err() + } + return nil, errors.New(NotTakingTasks(targetName)) + } + failed := true + defer func() { + if failed { + _ = stream.Close() + } + }() + stop := context.AfterFunc(timeout, func() { _ = stream.Close() }) + defer stop() + deadline, _ := timeout.Deadline() + setDeadline(stream, deadline) + tunnel, err := pair.ConnectWithKey(stream, target.PublicKey().Bytes(), key) + if err != nil { + if ctx.Err() != nil { + return nil, ctx.Err() + } + if errors.Is(err, pair.ErrNotThatMachine) { + return nil, errors.New(CouldNotConfirm(targetName)) + } + return nil, errors.New(NotTakingTasks(targetName)) + } + if err := sendHello(tunnel, dev); err != nil { + return nil, errors.New(NotTakingTasks(targetName)) + } + frame, err := ReadFrame(tunnel) + if err != nil { + if ctx.Err() != nil { + return nil, ctx.Err() + } + return nil, errors.New(NotTakingTasks(targetName)) + } + if frame.Type == "refused" { + var no Refused + if frame.Decode(&no) != nil || no.Reason == "" { + return nil, errors.New(CouldNotConfirm(targetName)) + } + return nil, errors.New(no.Reason) + } + var hello Hello + reason := "" + switch { + case frame.Type != "hello" || frame.Decode(&hello) != nil: + reason = CouldNotConfirm(targetName) + case hello.V != Version: + reason = DifferentVersion(targetName) + case hello.Cert.DeviceID() != targetDeviceID: + reason = CouldNotConfirm(targetName) + default: + if err := checkHello(id, tunnel, hello); err != nil { + reason = CouldNotConfirm(targetName) + } + } + if reason != "" { + _ = refuse(tunnel, reason) + return nil, errors.New(reason) + } + // Stop the handshake cancellation callback before handing ownership on. A + // callback already running has won the timeout and cannot yield a live Conn. + if !stop() || timeout.Err() != nil { + return nil, errors.New(NotTakingTasks(targetName)) + } + setDeadline(stream, time.Time{}) + failed = false + return newConn(ctx, tunnel, clock), nil +} +func setDeadline(stream io.ReadWriteCloser, when time.Time) { + if conn, ok := stream.(interface{ SetDeadline(time.Time) error }); ok { + _ = conn.SetDeadline(when) + } +} + +type readResult struct { + frame Frame + err error +} + +// Conn serializes writes across replies, watches and keepalive. A single reader +// answers pings even when the application is busy consuming earlier events. +type Conn struct { + tunnel *pair.Tunnel + clock Clock + sendMu sync.Mutex + incoming chan readResult + done chan struct{} + once sync.Once + stopContext func() bool + watching atomic.Bool + lastFrame atomic.Int64 + heartbeatOnce sync.Once + ping chan struct{} + pong chan struct{} + activity chan struct{} +} + +func newConn(ctx context.Context, tunnel *pair.Tunnel, clock Clock) *Conn { + c := &Conn{tunnel: tunnel, clock: clock, incoming: make(chan readResult, 32), done: make(chan struct{}), activity: make(chan struct{}, 1)} + c.lastFrame.Store(clock.Now().UnixNano()) + // Installing this callback before starting the reader avoids a concurrent + // Close observing a half-constructed connection. + c.stopContext = context.AfterFunc(ctx, func() { c.closeTransport() }) + go c.read() + return c +} +func (c *Conn) closeTransport() { c.once.Do(func() { close(c.done); _ = c.tunnel.Close() }) } +func (c *Conn) Close() error { c.stopContext(); c.closeTransport(); return nil } +func (c *Conn) read() { + defer close(c.incoming) + defer c.closeTransport() + for { + frame, err := ReadFrame(c.tunnel) + if err != nil { + select { + case c.incoming <- readResult{err: err}: + case <-c.done: + } + return + } + c.lastFrame.Store(c.clock.Now().UnixNano()) + c.noteActivity() + switch frame.Type { + case "ping": + // A blocked writer must not prevent the reader noticing other frames or + // the watchdog closing a silent peer. One queued pong is enough. + c.startHeartbeat() + c.queuePong() + continue + case "pong": + continue + case "finished", "unknown": + c.watching.Store(false) + } + select { + case c.incoming <- readResult{frame: frame}: + case <-c.done: + return + } + } +} +func (c *Conn) Receive() (Frame, error) { + result, ok := <-c.incoming + if !ok { + return Frame{}, io.EOF + } + return result.frame, result.err +} +func (c *Conn) sendFrame(frame Frame) error { + c.sendMu.Lock() + defer c.sendMu.Unlock() + select { + case <-c.done: + return io.ErrClosedPipe + default: + } + if err := WriteFrame(c.tunnel, frame); err != nil { + c.closeTransport() + return err + } + return nil +} +func (c *Conn) Send(attempt string, payload any) error { + frame, err := NewFrame(attempt, payload) + if err != nil { + return err + } + if frame.Type == "watch" { + c.startWatch() + } + return c.sendFrame(frame) +} diff --git a/internal/taskdoor/door_test.go b/internal/taskdoor/door_test.go new file mode 100644 index 0000000000..aa977c1d4f --- /dev/null +++ b/internal/taskdoor/door_test.go @@ -0,0 +1,431 @@ +package taskdoor_test + +import ( + "context" + "encoding/json" + "fmt" + "net/http/httptest" + "os" + "path/filepath" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/devname" + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/relay" + "github.com/Agent-Field/codeaf/internal/run" + "github.com/Agent-Field/codeaf/internal/taskdoor" +) + +type fakeExecutor struct { + calls atomic.Int32 + adopts atomic.Int32 + stops chan string + run func(context.Context, *taskdoor.Attempt, taskdoor.Journal) error + adopt func(context.Context, *taskdoor.Attempt, taskdoor.Journal) error +} + +func (f *fakeExecutor) Run(ctx context.Context, a *taskdoor.Attempt, j taskdoor.Journal) error { + f.calls.Add(1) + if f.run != nil { + return f.run(ctx, a, j) + } + return j.Append(taskdoor.Finished{Outcome: "done", Report: "the work is home", Model: "test-model"}) +} +func (f *fakeExecutor) Adopt(ctx context.Context, a *taskdoor.Attempt, j taskdoor.Journal) error { + f.adopts.Add(1) + if f.adopt != nil { + return f.adopt(ctx, a, j) + } + <-ctx.Done() + return ctx.Err() +} +func (f *fakeExecutor) Stop(id string) error { + if f.stops != nil { + f.stops <- id + } + return nil +} + +type rig struct { + t *testing.T + id identity.Identity + a, b identity.Dev + home, root, service string + executor *fakeExecutor + rosterMu sync.Mutex + active map[string]bool +} + +func newRig(t *testing.T) *rig { + t.Helper() + t.Setenv("HOME", t.TempDir()) + t.Setenv("CODEAF_HOME", t.TempDir()) + t.Setenv("CODEAF_TELEMETRY", "off") + t.Setenv("DO_NOT_TRACK", "1") + homeA, homeB := t.TempDir(), t.TempDir() + id, err := identity.Ensure(homeA) + if err != nil { + t.Fatal(err) + } + a, err := identity.LoadDevice(homeA) + if err != nil { + t.Fatal(err) + } + b, err := identity.NewDevice(id) + if err != nil { + t.Fatal(err) + } + if err := identity.Install(homeB, id, b); err != nil { + t.Fatal(err) + } + if _, err := devname.Set(homeB, "work computer"); err != nil { + t.Fatal(err) + } + relayServer := httptest.NewServer(&relay.Server{}) + t.Cleanup(relayServer.Close) + t.Cleanup(relayServer.CloseClientConnections) + return &rig{t: t, id: id, a: a, b: b, home: homeB, root: filepath.Join(homeB, "v3", "taskdoor"), service: relayServer.URL, executor: &fakeExecutor{}, active: map[string]bool{a.ID(): true, b.ID(): true}} +} +func (r *rig) config() taskdoor.Config { + return taskdoor.Config{Service: r.service, Identity: r.id, Device: r.b, Home: r.home, Root: r.root, Executor: r.executor, + Roster: func(context.Context) (map[string]bool, error) { + r.rosterMu.Lock() + defer r.rosterMu.Unlock() + out := map[string]bool{} + for k, v := range r.active { + out[k] = v + } + return out, nil + }} +} +func (r *rig) serve(config taskdoor.Config) func() { + r.t.Helper() + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + ready := make(chan struct{}, 1) + say := config.Say + config.Say = func(line string) { + if say != nil { + say(line) + } + if line == taskdoor.TakingTasksLine { + select { + case ready <- struct{}{}: + default: + } + } + } + go func() { done <- taskdoor.Serve(ctx, config) }() + var once sync.Once + stop := func() { + once.Do(func() { + cancel() + select { + case err := <-done: + if err != nil { + r.t.Errorf("Serve: %v", err) + } + case <-time.After(5 * time.Second): + r.t.Error("Serve did not stop") + } + }) + } + r.t.Cleanup(stop) + select { + case <-ready: + case err := <-done: + cancel() + r.t.Fatalf("Serve before ready: %v", err) + case <-time.After(5 * time.Second): + cancel() + r.t.Fatal("door never registered") + } + return stop +} +func (r *rig) dial() *taskdoor.Conn { + r.t.Helper() + ctx, cancel := context.WithCancel(context.Background()) + r.t.Cleanup(cancel) + c, err := taskdoor.Dial(ctx, r.service, r.id, r.a, r.b.ID(), "work computer") + if err != nil { + r.t.Fatal(err) + } + r.t.Cleanup(func() { _ = c.Close() }) + return c +} +func send(t *testing.T, c *taskdoor.Conn, id string, payload any) { + t.Helper() + if err := c.Send(id, payload); err != nil { + t.Fatal(err) + } +} +func receive(t *testing.T, c *taskdoor.Conn, kind string) taskdoor.Frame { + t.Helper() + result := make(chan taskdoor.Frame, 1) + failed := make(chan error, 1) + go func() { + frame, err := c.Receive() + if err != nil { + failed <- err + } else { + result <- frame + } + }() + select { + case frame := <-result: + if frame.Type != kind { + t.Fatalf("frame = %s, want %s (%s)", frame.Type, kind, frame.Payload) + } + return frame + case err := <-failed: + t.Fatal(err) + case <-time.After(5 * time.Second): + _ = c.Close() + t.Fatalf("no %s frame", kind) + } + return taskdoor.Frame{} +} +func request() taskdoor.Start { + return taskdoor.Start{Chat: "opaque-chat", Task: 7, Title: "secret title violet heron", Instruction: "secret instruction amber apples", Input: taskdoor.Head{Head: "head-input", Cell: "cell-task"}, OriginName: "home laptop"} +} +func attempt(n int) string { return fmt.Sprintf("att_%016x", n) } +func decode(t *testing.T, frame taskdoor.Frame, into any) { + t.Helper() + if err := frame.Decode(into); err != nil { + t.Fatal(err) + } +} + +// Contract 12: retrying admission, including after a door restart, must never +// replay an executor's tools. +func TestADoorTaskStartedTwiceRunsOnce(t *testing.T) { + r := newRig(t) + r.executor.run = func(ctx context.Context, a *taskdoor.Attempt, j taskdoor.Journal) error { + for _, name := range []string{"request.json", "state.json"} { + if _, err := os.Stat(filepath.Join(a.Dir, name)); err != nil { + return err + } + } + return j.Append(taskdoor.Finished{Outcome: "done", Report: "finished once", Model: "test-model"}) + } + stop := r.serve(r.config()) + c := r.dial() + id := attempt(1) + send(t, c, id, request()) + receive(t, c, "started") + send(t, c, id, request()) + receive(t, c, "started") + send(t, c, id, taskdoor.Watch{}) + receive(t, c, "finished") + _ = c.Close() + stop() + r.serve(r.config()) + c = r.dial() + send(t, c, id, request()) + var started taskdoor.Started + decode(t, receive(t, c, "started"), &started) + if started.State.Phase != "done" { + t.Fatalf("recorded state = %+v", started.State) + } + if got := r.executor.calls.Load(); got != 1 { + t.Fatalf("executor ran %d times", got) + } + if got := r.executor.adopts.Load(); got != 0 { + t.Fatalf("completed attempt adopted %d times", got) + } +} + +// Contract 13: holding the identity seed is insufficient after device removal. +func TestARemovedComputerIsTurnedAwayAtTheDoor(t *testing.T) { + r := newRig(t) + r.active[r.a.ID()] = false + r.serve(r.config()) + c, err := taskdoor.Dial(context.Background(), r.service, r.id, r.a, r.b.ID(), "work computer") + if c != nil { + _ = c.Close() + } + if err == nil || err.Error() != "this computer was removed from your devices" { + t.Fatalf("removed device heard %v", err) + } + if r.executor.calls.Load() != 0 { + t.Fatal("removed device started work") + } +} + +// Contract 8's wire half: an absent target gives the actionable serving line. +func TestADoorThatIsNotServingSaysSo(t *testing.T) { + r := newRig(t) + since := time.Now() + c, err := taskdoor.Dial(context.Background(), r.service, r.id, r.a, r.b.ID(), "work computer") + if c != nil { + _ = c.Close() + } + if err == nil || err.Error() != "work computer is not taking tasks right now — run codeaf serve on it" { + t.Fatalf("absent door heard %v", err) + } + if time.Since(since) > 10*time.Second { + t.Fatal("absent door exceeded the dial timeout") + } +} + +// Contract 9's wire half: a watch replays only the durable events the origin +// has not received, even when it reconnects while the worker is still running. +func TestAWatchResumesFromItsLastSeq(t *testing.T) { + r := newRig(t) + advance := make(chan struct{}) + first := make(chan struct{}) + r.executor.run = func(ctx context.Context, a *taskdoor.Attempt, j taskdoor.Journal) error { + if err := j.Append(taskdoor.Step{Step: run.Step{Kind: "step", Step: 1, Command: "first step"}}); err != nil { + return err + } + close(first) + select { + case <-advance: + case <-ctx.Done(): + return ctx.Err() + } + if err := j.Append(taskdoor.Step{Step: run.Step{Kind: "step", Step: 2, Command: "second step"}}); err != nil { + return err + } + if err := j.Append(taskdoor.Spend{Line: taskdoor.SpendLine{Model: "test-model", Seat: "worker", InputTokens: 10, OutputTokens: 20, CachedTokens: 3, USD: 0.01}}); err != nil { + return err + } + return j.Append(taskdoor.Finished{Outcome: "done", Report: "all done", Changed: []string{"out.txt"}, Model: "test-model", Result: &taskdoor.Head{Head: "result-head", Cell: "task-cell"}}) + } + r.serve(r.config()) + c := r.dial() + id := attempt(2) + send(t, c, id, request()) + receive(t, c, "started") + <-first + send(t, c, id, taskdoor.Watch{}) + frame := receive(t, c, "step") + if frame.Seq != 1 { + t.Fatalf("first seq = %d", frame.Seq) + } + _ = c.Close() + close(advance) + c = r.dial() + send(t, c, id, taskdoor.Watch{From: frame.Seq}) + for i, kind := range []string{"step", "spend", "finished"} { + frame = receive(t, c, kind) + if frame.Seq != uint64(i+2) { + t.Fatalf("replay seq = %d", frame.Seq) + } + } + var end taskdoor.Finished + decode(t, frame, &end) + if end.Result == nil || end.Result.Head != "result-head" || end.Model != "test-model" { + t.Fatalf("lost result: %+v", end) + } +} + +// Contract 12's admission boundary also limits work to two concurrent attempts. +func TestAThirdTaskAtOnceIsRefused(t *testing.T) { + r := newRig(t) + running := make(chan struct{}, 2) + r.executor.run = func(ctx context.Context, _ *taskdoor.Attempt, _ taskdoor.Journal) error { + running <- struct{}{} + <-ctx.Done() + return ctx.Err() + } + r.serve(r.config()) + c := r.dial() + for n := 1; n <= 2; n++ { + send(t, c, attempt(n), request()) + receive(t, c, "started") + <-running + } + send(t, c, attempt(3), request()) + var refusal taskdoor.Refused + decode(t, receive(t, c, "refused"), &refusal) + if refusal.Reason != "work computer is already running two tasks" { + t.Fatalf("busy line = %q", refusal.Reason) + } + if r.executor.calls.Load() != 2 { + t.Fatal("third executor ran") + } +} + +// Contracts 11 and 12 retain attempts until the origin explicitly acknowledges +// either landing the result or abandoning it. +func TestAnAckRemovesTheAttempt(t *testing.T) { + for _, disposition := range []string{"landed", "abandoned"} { + t.Run(disposition, func(t *testing.T) { + r := newRig(t) + r.serve(r.config()) + c := r.dial() + id := attempt(4) + send(t, c, id, request()) + receive(t, c, "started") + send(t, c, id, taskdoor.Watch{}) + receive(t, c, "finished") + send(t, c, id, taskdoor.Ack{Disposition: disposition}) + receive(t, c, "acked") + if _, err := os.Stat(filepath.Join(r.root, id)); !os.IsNotExist(err) { + t.Fatalf("ack left attempt folder: %v", err) + } + send(t, c, id, taskdoor.Watch{}) + receive(t, c, "unknown") + }) + } +} + +// Contract 12's restart rule adopts running attempts instead of replaying Run. +func TestARunningAttemptIsAdoptedAfterServeRestarts(t *testing.T) { + r := newRig(t) + running := make(chan struct{}, 1) + r.executor.run = func(ctx context.Context, a *taskdoor.Attempt, j taskdoor.Journal) error { + if err := a.UpdateState(func(s *taskdoor.State) { s.PID = 123 }); err != nil { + return err + } + if err := j.Append(taskdoor.Phase{Words: "running before restart"}); err != nil { + return err + } + running <- struct{}{} + <-ctx.Done() + return ctx.Err() + } + r.executor.adopt = func(ctx context.Context, a *taskdoor.Attempt, j taskdoor.Journal) error { + if a.State().PID != 123 { + return fmt.Errorf("lost child pid") + } + return j.Append(taskdoor.Finished{Outcome: "interrupted", Report: "the child stopped"}) + } + stop := r.serve(r.config()) + c := r.dial() + id := attempt(5) + send(t, c, id, request()) + receive(t, c, "started") + <-running + _ = c.Close() + stop() + r.serve(r.config()) + c = r.dial() + send(t, c, id, taskdoor.Watch{}) + if frame := receive(t, c, "phase"); frame.Seq != 1 { + t.Fatalf("saved phase seq = %d", frame.Seq) + } + if frame := receive(t, c, "finished"); frame.Seq != 2 { + t.Fatalf("adopted finish seq = %d", frame.Seq) + } + send(t, c, id, request()) + receive(t, c, "started") + if r.executor.calls.Load() != 1 || r.executor.adopts.Load() != 1 { + t.Fatalf("run/adopt = %d/%d", r.executor.calls.Load(), r.executor.adopts.Load()) + } + raw, err := os.ReadFile(filepath.Join(r.root, id, "result.json")) + if err != nil { + t.Fatal(err) + } + var result taskdoor.Finished + if err := json.Unmarshal(raw, &result); err != nil { + t.Fatal(err) + } + if result.Outcome != "interrupted" { + t.Fatalf("saved outcome = %q", result.Outcome) + } +} diff --git a/internal/taskdoor/frames.go b/internal/taskdoor/frames.go new file mode 100644 index 0000000000..172be8b5d0 --- /dev/null +++ b/internal/taskdoor/frames.go @@ -0,0 +1,234 @@ +package taskdoor + +import ( + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "io" + + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/run" +) + +const ( + // Version is the task wire version, checked before accepting any work. + Version = 1 + // MaxFrameBytes bounds allocation before an untrusted length is read. + MaxFrameBytes = 1 << 20 +) + +// Frame retains the complete JSON object so new readers can decode a typed +// payload without losing the envelope that is also saved in the journal. +type Frame struct { + Type string `json:"t"` + Attempt string `json:"attempt,omitempty"` + Seq uint64 `json:"seq,omitempty"` + Payload json.RawMessage `json:"-"` +} + +func (f Frame) Decode(into any) error { return json.Unmarshal(f.Payload, into) } + +// Head identifies a task-only cell, independently of the chat's own head. +type Head struct { + Head string `json:"head"` + Cell string `json:"cell"` +} +type Limits struct { + USD float64 `json:"usd,omitempty"` + Hours float64 `json:"hours,omitempty"` +} +type Hello struct { + V int `json:"v"` + Cert identity.Cert `json:"cert"` + Sig []byte `json:"sig"` +} +type Start struct { + Chat string `json:"chat"` + Task int `json:"task"` + Title string `json:"title"` + Instruction string `json:"instruction"` + Model string `json:"model,omitempty"` + Effort string `json:"effort,omitempty"` + MaxSteps int `json:"max_steps,omitempty"` + NoProgress int `json:"no_progress,omitempty"` + Input Head `json:"input"` + Limits Limits `json:"limits"` + OriginName string `json:"origin_name"` +} +type Started struct { + State State `json:"state"` +} +type Refused struct { + Reason string `json:"reason"` +} +type Watch struct { + From uint64 `json:"from"` +} +type Step struct { + Step run.Step `json:"step"` +} + +// SpendLine carries one charge, so the origin can attribute it once rather +// than estimating spend again from an attempt's final totals. +type SpendLine struct { + Model string `json:"model"` + Seat string `json:"seat"` + InputTokens int `json:"input_tokens,omitempty"` + OutputTokens int `json:"output_tokens,omitempty"` + CachedTokens int `json:"cached_tokens,omitempty"` + USD float64 `json:"usd,omitempty"` +} +type Spend struct { + Line SpendLine `json:"line"` +} +type Phase struct { + Words string `json:"words"` +} +type Finished struct { + Outcome string `json:"outcome"` + Report string `json:"report"` + Changed []string `json:"changed"` + Model string `json:"model"` + Result *Head `json:"result,omitempty"` +} +type Unknown struct{} +type Stop struct{} +type Stopping struct{} +type Ack struct { + Disposition string `json:"disposition"` +} +type Acked struct{} +type Ping struct{} +type Pong struct{} + +func payloadType(payload any) string { + switch payload.(type) { + case Hello: + return "hello" + case Start: + return "start" + case Started: + return "started" + case Refused: + return "refused" + case Watch: + return "watch" + case Step: + return "step" + case Spend: + return "spend" + case Phase: + return "phase" + case Finished: + return "finished" + case Unknown: + return "unknown" + case Stop: + return "stop" + case Stopping: + return "stopping" + case Ack: + return "ack" + case Acked: + return "acked" + case Ping: + return "ping" + case Pong: + return "pong" + default: + return "" + } +} + +// NewFrame gives each payload its wire type, so callers cannot accidentally +// label a stop request as a start. +func NewFrame(attempt string, payload any) (Frame, error) { return makeFrame(attempt, 0, payload) } +func makeFrame(attempt string, seq uint64, payload any) (Frame, error) { + kind := payloadType(payload) + if kind == "" { + return Frame{}, errors.New("taskdoor: unsupported frame payload") + } + raw, err := json.Marshal(payload) + if err != nil { + return Frame{}, err + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(raw, &fields); err != nil { + return Frame{}, err + } + fields["t"], _ = json.Marshal(kind) + if attempt != "" { + fields["attempt"], _ = json.Marshal(attempt) + } + if seq != 0 { + fields["seq"], _ = json.Marshal(seq) + } + raw, err = json.Marshal(fields) + if err != nil { + return Frame{}, err + } + if len(raw) > MaxFrameBytes { + return Frame{}, frameTooLarge() + } + return Frame{Type: kind, Attempt: attempt, Seq: seq, Payload: raw}, nil +} +func frameTooLarge() error { + return fmt.Errorf("taskdoor: a frame exceeds the %d-byte limit", MaxFrameBytes) +} +func parseFrame(raw []byte) (Frame, error) { + var f Frame + if err := json.Unmarshal(raw, &f); err != nil { + return Frame{}, err + } + if f.Type == "" { + return Frame{}, errors.New("taskdoor: a frame needs a type") + } + f.Payload = raw + return f, nil +} + +// ReadFrame checks the length before allocating, including on encrypted but +// not yet authorized hello connections. +func ReadFrame(r io.Reader) (Frame, error) { + var head [4]byte + if _, err := io.ReadFull(r, head[:]); err != nil { + return Frame{}, err + } + size := binary.BigEndian.Uint32(head[:]) + if size > MaxFrameBytes { + return Frame{}, frameTooLarge() + } + if size == 0 { + return Frame{}, errors.New("taskdoor: empty frame") + } + raw := make([]byte, int(size)) + if _, err := io.ReadFull(r, raw); err != nil { + return Frame{}, err + } + return parseFrame(raw) +} + +// WriteFrame writes one complete length-prefixed object. Connections serialize +// this call because a large JSON object may span several encrypted records. +func WriteFrame(w io.Writer, f Frame) error { + if len(f.Payload) > MaxFrameBytes { + return frameTooLarge() + } + if len(f.Payload) == 0 { + return errors.New("taskdoor: empty frame") + } + data := binary.BigEndian.AppendUint32(nil, uint32(len(f.Payload))) + data = append(data, f.Payload...) + for len(data) > 0 { + n, err := w.Write(data) + if err != nil { + return err + } + if n == 0 { + return io.ErrShortWrite + } + data = data[n:] + } + return nil +} diff --git a/internal/taskdoor/frames_test.go b/internal/taskdoor/frames_test.go new file mode 100644 index 0000000000..b85cca7d6d --- /dev/null +++ b/internal/taskdoor/frames_test.go @@ -0,0 +1,48 @@ +package taskdoor_test + +import ( + "bytes" + "encoding/json" + "testing" + + "github.com/Agent-Field/codeaf/internal/taskdoor" +) + +// Contracts 3, 5 and 9 need the settled task inputs, exact worker model and +// per-call charge fields preserved on the task wire, independently of a chat. +func TestTheWirePreservesTaskInputsAndOneSpendLine(t *testing.T) { + cases := []struct { + payload any + want string + }{ + {taskdoor.Start{Chat: "chat", Task: 9, Title: "title", Instruction: "brief", Model: "model", Effort: "high", MaxSteps: 17, NoProgress: 4, Input: taskdoor.Head{Head: "h", Cell: "c"}, Limits: taskdoor.Limits{USD: 2.5, Hours: 0.5}, OriginName: "origin"}, `{"t":"start","attempt":"att_0000000000000001","chat":"chat","task":9,"title":"title","instruction":"brief","model":"model","effort":"high","max_steps":17,"no_progress":4,"input":{"head":"h","cell":"c"},"limits":{"usd":2.5,"hours":0.5},"origin_name":"origin"}`}, + {taskdoor.Spend{Line: taskdoor.SpendLine{Model: "model", Seat: "worker", InputTokens: 100, OutputTokens: 20, CachedTokens: 7, USD: 0.03}}, `{"t":"spend","attempt":"att_0000000000000001","line":{"model":"model","seat":"worker","input_tokens":100,"output_tokens":20,"cached_tokens":7,"usd":0.03}}`}, + {taskdoor.Finished{Outcome: "done", Report: "report", Changed: []string{"out.txt"}, Model: "model", Result: &taskdoor.Head{Head: "result", Cell: "cell"}}, `{"t":"finished","attempt":"att_0000000000000001","outcome":"done","report":"report","changed":["out.txt"],"model":"model","result":{"head":"result","cell":"cell"}}`}, + } + for _, test := range cases { + frame, err := taskdoor.NewFrame(attempt(1), test.payload) + if err != nil { + t.Fatal(err) + } + var stream bytes.Buffer + if err := taskdoor.WriteFrame(&stream, frame); err != nil { + t.Fatal(err) + } + got, err := taskdoor.ReadFrame(&stream) + if err != nil { + t.Fatal(err) + } + var actual, expected any + if err := json.Unmarshal(got.Payload, &actual); err != nil { + t.Fatal(err) + } + if err := json.Unmarshal([]byte(test.want), &expected); err != nil { + t.Fatal(err) + } + a, _ := json.Marshal(actual) + b, _ := json.Marshal(expected) + if !bytes.Equal(a, b) { + t.Fatalf("wire = %s, want %s", a, b) + } + } +} diff --git a/internal/taskdoor/hello.go b/internal/taskdoor/hello.go new file mode 100644 index 0000000000..812acc4d83 --- /dev/null +++ b/internal/taskdoor/hello.go @@ -0,0 +1,128 @@ +package taskdoor + +import ( + "bytes" + "context" + "crypto/ed25519" + "encoding/hex" + "errors" + "strconv" + "sync" + "time" + + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/pair" +) + +const ( + RemovedComputerLine = "this computer was removed from your devices" + TakingTasksLine = "takes tasks from your paired computers" +) + +func NotTakingTasks(name string) string { + return name + " is not taking tasks right now — run codeaf serve on it" +} +func CouldNotConfirm(name string) string { return "could not confirm this is " + name } +func DifferentVersion(name string) string { + return name + " runs a different codeaf that cannot take this task — update both" +} +func Busy(name string) string { + // The words come from the admission cap, rather than a second limit hidden + // in a sentence that could become wrong when capacity changes. + return name + " is already running " + numberWord(MaxRunning) + " tasks" +} +func numberWord(n int) string { + if n == 2 { + return "two" + } + return strconv.Itoa(n) +} + +// rosterCache refuses stale authority after its bounded lifetime. Failed +// refreshes never extend the previous roster, even during an outage. +type rosterCache struct { + fetch func(context.Context) (map[string]bool, error) + clock Clock + mu sync.Mutex + active map[string]bool + fetched time.Time +} + +func (r *rosterCache) allows(ctx context.Context, device string) (bool, error) { + r.mu.Lock() + defer r.mu.Unlock() + now := r.clock.Now() + if r.active == nil || now.Before(r.fetched) || now.Sub(r.fetched) >= RosterFor { + rows, err := r.fetch(ctx) + if err != nil { + return false, err + } + r.active = make(map[string]bool, len(rows)) + for k, v := range rows { + r.active[k] = v + } + r.fetched = now + } + return r.active[device], nil +} +func helloFor(dev identity.Dev, tunnel *pair.Tunnel) Hello { + return Hello{V: Version, Cert: dev.Cert, Sig: dev.Sign(tunnel.ChannelBinding())} +} +func checkHello(id identity.Identity, tunnel *pair.Tunnel, hello Hello) error { + if hello.Cert.V != 1 || hello.Cert.Verify(id.PublicKey()) != nil { + return errors.New("could not confirm this computer") + } + pub, err := hex.DecodeString(hello.Cert.Device) + if err != nil || len(pub) != ed25519.PublicKeySize || !ed25519.Verify(ed25519.PublicKey(pub), tunnel.ChannelBinding(), hello.Sig) { + return errors.New("could not confirm this computer") + } + key, err := DoorKey(id, hello.Cert.DeviceID()) + if err != nil || !bytes.Equal(key.PublicKey().Bytes(), tunnel.Peer()) { + return errors.New("could not confirm this computer") + } + return nil +} +func sendHello(tunnel *pair.Tunnel, dev identity.Dev) error { + frame, err := NewFrame("", helloFor(dev, tunnel)) + if err != nil { + return err + } + return WriteFrame(tunnel, frame) +} +func refuse(tunnel *pair.Tunnel, reason string) error { + frame, err := NewFrame("", Refused{Reason: reason}) + if err != nil { + return err + } + return WriteFrame(tunnel, frame) +} +func acceptHello(ctx context.Context, tunnel *pair.Tunnel, id identity.Identity, dev identity.Dev, roster *rosterCache, name string) error { + frame, err := ReadFrame(tunnel) + if err != nil { + return err + } + var hello Hello + reason := "" + switch { + case frame.Type != "hello" || frame.Decode(&hello) != nil: + reason = "could not confirm this computer" + case hello.V != Version: + reason = DifferentVersion(name) + default: + if err := checkHello(id, tunnel, hello); err != nil { + reason = err.Error() + } else { + active, err := roster.allows(ctx, hello.Cert.DeviceID()) + if err != nil { + reason = "could not check your devices right now" + } else if !active { + reason = RemovedComputerLine + } + } + } + if reason != "" { + _ = refuse(tunnel, reason) + return errors.New(reason) + } + return sendHello(tunnel, dev) +} diff --git a/internal/taskdoor/keepalive.go b/internal/taskdoor/keepalive.go new file mode 100644 index 0000000000..632ed70832 --- /dev/null +++ b/internal/taskdoor/keepalive.go @@ -0,0 +1,93 @@ +package taskdoor + +import "time" + +// Keepalive has its own writer so a peer that stops reading cannot block the +// clock loop that must eventually close that very peer. +func (c *Conn) startHeartbeat() { + c.heartbeatOnce.Do(func() { + c.ping = make(chan struct{}, 1) + c.pong = make(chan struct{}, 1) + ticker := c.clock.NewTicker(PingEvery) + silence := c.clock.NewTicker(SilentAfter) + go func() { + defer ticker.Stop() + defer func() { silence.Stop() }() + resetSilence := func() bool { + remaining := SilentAfter - time.Duration(c.clock.Now().UnixNano()-c.lastFrame.Load()) + if remaining <= 0 { + c.closeTransport() + return false + } + silence.Stop() + silence = c.clock.NewTicker(remaining) + return true + } + for { + select { + case <-c.done: + return + case <-c.activity: + if c.watching.Load() && !resetSilence() { + return + } + case <-silence.C(): + if c.watching.Load() && !resetSilence() { + return + } + case <-ticker.C(): + if !c.watching.Load() { + continue + } + if c.clock.Now().UnixNano()-c.lastFrame.Load() >= int64(SilentAfter) { + c.closeTransport() + return + } + select { + case c.ping <- struct{}{}: + default: + } + } + } + }() + go func() { + for { + var payload any + select { + case <-c.done: + return + case <-c.ping: + payload = Ping{} + case <-c.pong: + payload = Pong{} + } + frame, err := NewFrame("", payload) + if err != nil || c.sendFrame(frame) != nil { + return + } + } + }() + }) +} +func (c *Conn) queuePong() { + select { + case c.pong <- struct{}{}: + default: + } +} +func (c *Conn) startWatch() { + if !c.watching.Swap(true) { + c.lastFrame.Store(c.clock.Now().UnixNano()) + } + c.startHeartbeat() + c.noteActivity() +} + +// A separate silence wakeup preserves the full interval after every frame; +// checking only on probe ticks could otherwise close a connection late. +func (c *Conn) noteActivity() { + select { + case c.activity <- struct{}{}: + default: + } +} diff --git a/internal/taskdoor/keepalive_test.go b/internal/taskdoor/keepalive_test.go new file mode 100644 index 0000000000..a40200d376 --- /dev/null +++ b/internal/taskdoor/keepalive_test.go @@ -0,0 +1,278 @@ +package taskdoor_test + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/pair" + "github.com/Agent-Field/codeaf/internal/relay" + "github.com/Agent-Field/codeaf/internal/taskdoor" +) + +type manualClock struct { + mu sync.Mutex + now time.Time + tickers map[*manualTicker]bool + created chan struct{} +} +type manualTicker struct { + clock *manualClock + every time.Duration + next time.Time + ch chan time.Time +} + +func newClock() *manualClock { + return &manualClock{now: time.Now(), tickers: map[*manualTicker]bool{}, created: make(chan struct{}, 64)} +} +func (c *manualClock) Now() time.Time { c.mu.Lock(); defer c.mu.Unlock(); return c.now } +func (c *manualClock) NewTicker(d time.Duration) taskdoor.Ticker { + c.mu.Lock() + defer c.mu.Unlock() + t := &manualTicker{clock: c, every: d, next: c.now.Add(d), ch: make(chan time.Time, 64)} + c.tickers[t] = true + c.created <- struct{}{} + return t +} +func (t *manualTicker) C() <-chan time.Time { return t.ch } +func (t *manualTicker) Stop() { + t.clock.mu.Lock() + defer t.clock.mu.Unlock() + delete(t.clock.tickers, t) +} +func (c *manualClock) advance(d time.Duration) { + c.mu.Lock() + defer c.mu.Unlock() + c.now = c.now.Add(d) + for ticker := range c.tickers { + for !ticker.next.After(c.now) { + ticker.ch <- ticker.next + ticker.next = ticker.next.Add(ticker.every) + } + } +} +func (c *manualClock) awaitTicker(t *testing.T) { + t.Helper() + select { + case <-c.created: + case <-time.After(5 * time.Second): + t.Fatal("watch never enabled keepalive") + } +} + +// Contract 9's watch boundary must detect a silent peer after 45 seconds and +// send its own probes at 15 and 30, without waiting for wall-clock time. +func TestASilentWatchIsClosedAfterFortyFiveSeconds(t *testing.T) { + r := newRig(t) + clock := newClock() + config := r.config() + config.Clock = clock + r.executor.run = func(ctx context.Context, _ *taskdoor.Attempt, _ taskdoor.Journal) error { + <-ctx.Done() + return ctx.Err() + } + r.serve(config) + control := r.dial() + id := attempt(20) + send(t, control, id, request()) + receive(t, control, "started") + key, err := taskdoor.DoorKey(r.id, r.a.ID()) + if err != nil { + t.Fatal(err) + } + tunnel := rawTunnel(t, r, key) + rawHello(t, tunnel, r.a) + rawSend(t, tunnel, id, taskdoor.Watch{}) + clock.awaitTicker(t) + for i := 0; i < 2; i++ { + clock.advance(15 * time.Second) + rawReceive(t, tunnel, "ping") + } + closed := make(chan error, 1) + go func() { _, err := taskdoor.ReadFrame(tunnel); closed <- err }() + clock.advance(14 * time.Second) + select { + case err := <-closed: + t.Fatalf("watch closed before 45 seconds: %v", err) + case <-time.After(10 * time.Millisecond): + } + clock.advance(time.Second) + select { + case err := <-closed: + if err == nil { + t.Fatal("silent watch sent another frame instead of closing") + } + case <-time.After(5 * time.Second): + t.Fatal("silent watch stayed open after 45 seconds") + } + if r.executor.calls.Load() != 1 { + t.Fatal("losing the watch restarted the attempt") + } +} + +// Contract 13 allows a bounded roster cache, never a stale roster extended +// beyond sixty seconds after another computer was removed. +func TestARosterRefreshTurnsAwayAComputerWithinSixtySeconds(t *testing.T) { + r := newRig(t) + clock := newClock() + config := r.config() + config.Clock = clock + r.serve(config) + c := r.dial() + _ = c.Close() + r.rosterMu.Lock() + r.active[r.a.ID()] = false + r.rosterMu.Unlock() + clock.advance(59 * time.Second) + c = r.dial() + _ = c.Close() + clock.advance(time.Second) + c, err := taskdoor.Dial(context.Background(), r.service, r.id, r.a, r.b.ID(), "work computer") + if c != nil { + _ = c.Close() + } + if err == nil || err.Error() != "this computer was removed from your devices" { + t.Fatalf("stale roster allowed a removed computer: %v", err) + } +} + +// Contract 9 measures silence from any received frame, rather than rounding a +// fresh arrival to the next fifteen-second probe interval. +func TestEveryWatchFrameRestartsTheFortyFiveSecondSilenceLimit(t *testing.T) { + r := newRig(t) + clock := newClock() + config := r.config() + config.Clock = clock + r.executor.run = func(ctx context.Context, _ *taskdoor.Attempt, _ taskdoor.Journal) error { + <-ctx.Done() + return ctx.Err() + } + r.serve(config) + control := r.dial() + id := attempt(21) + send(t, control, id, request()) + receive(t, control, "started") + key, err := taskdoor.DoorKey(r.id, r.a.ID()) + if err != nil { + t.Fatal(err) + } + tunnel := rawTunnel(t, r, key) + rawHello(t, tunnel, r.a) + rawSend(t, tunnel, id, taskdoor.Watch{}) + clock.awaitTicker(t) + clock.advance(7 * time.Second) + rawSend(t, tunnel, "", taskdoor.Ping{}) + rawReceive(t, tunnel, "pong") + closed := make(chan error, 1) + go func() { + for { + _, err := taskdoor.ReadFrame(tunnel) + if err != nil { + closed <- err + return + } + } + }() + clock.advance(38 * time.Second) + select { + case err := <-closed: + t.Fatalf("closed only 38 seconds after a frame: %v", err) + case <-time.After(10 * time.Millisecond): + } + clock.advance(7 * time.Second) + select { + case <-closed: + case <-time.After(5 * time.Second): + t.Fatal("silence timeout was rounded beyond 45 seconds since the last frame") + } +} + +// Contract 9 applies the same probes and silence limit at the origin; a +// responder that stops producing frames must not leave the origin waiting. +func TestTheOriginAlsoProbesAndClosesASilentWatch(t *testing.T) { + r := newRig(t) + clock := newClock() + key, err := taskdoor.DoorKey(r.id, r.b.ID()) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + registration, err := relay.Register(ctx, r.service, key) + if err != nil { + t.Fatal(err) + } + defer registration.Close() + frames := make(chan string, 8) + failed := make(chan error, 1) + go func() { + stream, err := registration.Accept() + if err != nil { + failed <- err + return + } + defer stream.Close() + tunnel, err := pair.AcceptWithKey(stream, key) + if err != nil { + failed <- err + return + } + if _, err := taskdoor.ReadFrame(tunnel); err != nil { + failed <- err + return + } + hello, err := taskdoor.NewFrame("", taskdoor.Hello{V: 1, Cert: r.b.Cert, Sig: r.b.Sign(tunnel.ChannelBinding())}) + if err != nil { + failed <- err + return + } + if err := taskdoor.WriteFrame(tunnel, hello); err != nil { + failed <- err + return + } + for { + f, err := taskdoor.ReadFrame(tunnel) + if err != nil { + return + } + frames <- f.Type + } + }() + conn, err := taskdoor.DialWithClock(ctx, r.service, r.id, r.a, r.b.ID(), "work computer", clock) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + send(t, conn, attempt(22), taskdoor.Watch{}) + next := func(want string) { + t.Helper() + select { + case got := <-frames: + if got != want { + t.Fatalf("origin sent %s, want %s", got, want) + } + case err := <-failed: + t.Fatal(err) + case <-time.After(5 * time.Second): + t.Fatalf("origin did not send %s", want) + } + } + next("watch") + for n := 0; n < 2; n++ { + clock.advance(15 * time.Second) + next("ping") + } + clock.advance(15 * time.Second) + closed := make(chan error, 1) + go func() { _, err := conn.Receive(); closed <- err }() + select { + case err := <-closed: + if err == nil { + t.Fatal("origin returned a task event from a silent peer") + } + case <-time.After(5 * time.Second): + t.Fatal("origin did not close its silent watch") + } +} diff --git a/internal/taskdoor/keys.go b/internal/taskdoor/keys.go new file mode 100644 index 0000000000..e9b4665976 --- /dev/null +++ b/internal/taskdoor/keys.go @@ -0,0 +1,34 @@ +// Package taskdoor carries task attempts between devices of one identity. +// The relay only routes encrypted streams; device certificates decide who may +// submit work, independently of the machine-access pairing book. +package taskdoor + +import ( + "crypto/ecdh" + "crypto/hkdf" + "crypto/sha256" + + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/relay" +) + +const doorKeySalt = "codeaf task door v1" + +// DoorKey derives a device's task key without publishing another directory +// entry. A cell-key rotation therefore also changes every task door. +func DoorKey(id identity.Identity, deviceID string) (*ecdh.PrivateKey, error) { + raw, err := hkdf.Key(sha256.New, id.CellKey(), []byte(doorKeySalt), deviceID, 32) + if err != nil { + return nil, err + } + return ecdh.X25519().NewPrivateKey(raw) +} + +// DoorName is the relay rendezvous name of the device's derived public key. +func DoorName(id identity.Identity, deviceID string) (string, error) { + key, err := DoorKey(id, deviceID) + if err != nil { + return "", err + } + return relay.NameFor(key.PublicKey().Bytes()), nil +} diff --git a/internal/taskdoor/keys_test.go b/internal/taskdoor/keys_test.go new file mode 100644 index 0000000000..4703de3ecc --- /dev/null +++ b/internal/taskdoor/keys_test.go @@ -0,0 +1,40 @@ +package taskdoor_test + +import ( + "encoding/hex" + "testing" + + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/relay" + "github.com/Agent-Field/codeaf/internal/taskdoor" +) + +// Contract 14 requires a private door derived from the shared identity, with a +// different key for each device so the relay can register both at once. +func TestDoorKeysHaveStableDeviceSpecificNames(t *testing.T) { + id, err := identity.Unmarshal([]byte(`{"V":1,"signing_seed":"0000000000000000000000000000000000000000000000000000000000000000","dedup_secret":"0000000000000000000000000000000000000000000000000000000000000000","cell_key":"000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"}`)) + if err != nil { + t.Fatal(err) + } + key, err := taskdoor.DoorKey(id, "dev_0123456789abcdef0123456789abcdef") + if err != nil { + t.Fatal(err) + } + if got := hex.EncodeToString(key.Bytes()); got != "ab6e375a971e40662d79e86158ad7876fb3578b9d6c99b2a74bfb2d9b1989bdd" { + t.Fatalf("derived private key = %s", got) + } + name, err := taskdoor.DoorName(id, "dev_0123456789abcdef0123456789abcdef") + if err != nil { + t.Fatal(err) + } + if name != relay.NameFor(key.PublicKey().Bytes()) { + t.Fatalf("name %q does not register under its public key", name) + } + other, err := taskdoor.DoorName(id, "dev_another") + if err != nil { + t.Fatal(err) + } + if name == other { + t.Fatal("two device doors have the same name") + } +} diff --git a/internal/taskdoor/security_test.go b/internal/taskdoor/security_test.go new file mode 100644 index 0000000000..3a1c75f4c7 --- /dev/null +++ b/internal/taskdoor/security_test.go @@ -0,0 +1,424 @@ +package taskdoor_test + +import ( + "bytes" + "context" + "crypto/ecdh" + "encoding/binary" + "io" + "net" + "net/http" + "net/http/httptest" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/pair" + "github.com/Agent-Field/codeaf/internal/relay" + "github.com/Agent-Field/codeaf/internal/run" + "github.com/Agent-Field/codeaf/internal/taskdoor" +) + +type wireTap struct { + address string + mu sync.Mutex + seen bytes.Buffer + connections []net.Conn +} + +func (w *wireTap) Write(p []byte) (int, error) { + w.mu.Lock() + defer w.mu.Unlock() + return w.seen.Write(p) +} +func (w *wireTap) bytes() []byte { + w.mu.Lock() + defer w.mu.Unlock() + return bytes.Clone(w.seen.Bytes()) +} +func tapRelay(t *testing.T, upstream string) *wireTap { + t.Helper() + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = listener.Close() }) + tap := &wireTap{address: "http://" + listener.Addr().String()} + go func() { + for { + near, err := listener.Accept() + if err != nil { + return + } + far, err := net.Dial("tcp", strings.TrimPrefix(upstream, "http://")) + if err != nil { + _ = near.Close() + return + } + tap.mu.Lock() + tap.connections = append(tap.connections, near, far) + tap.mu.Unlock() + go func() { _, _ = io.Copy(io.MultiWriter(far, tap), near); _ = far.Close() }() + go func() { _, _ = io.Copy(io.MultiWriter(near, tap), far); _ = near.Close() }() + } + }() + t.Cleanup(tap.drop) + return tap +} + +// Contract 14: recording every byte at the actual relay sockets must reveal +// neither admission text nor streamed work, even while a watch is attached. +func TestTheRelaySeesNoTaskWords(t *testing.T) { + r := newRig(t) + tap := tapRelay(t, r.service) + r.service = tap.address + r.executor.run = func(ctx context.Context, a *taskdoor.Attempt, j taskdoor.Journal) error { + if err := j.Append(taskdoor.Step{Step: run.Step{Kind: "step", Step: 1, Command: "secret step cobalt cabbage"}}); err != nil { + return err + } + if err := j.Append(taskdoor.Phase{Words: "secret phase copper carrots"}); err != nil { + return err + } + return j.Append(taskdoor.Finished{Outcome: "done", Report: "secret report salmon squash", Model: "test-model"}) + } + r.serve(r.config()) + c := r.dial() + id := attempt(10) + send(t, c, id, request()) + receive(t, c, "started") + send(t, c, id, taskdoor.Watch{}) + receive(t, c, "step") + receive(t, c, "phase") + receive(t, c, "finished") + traffic := tap.bytes() + if len(traffic) == 0 { + t.Fatal("relay recorder saw no traffic") + } + for _, secret := range []string{request().Instruction, request().Title, "secret step cobalt cabbage", "secret phase copper carrots", "secret report salmon squash"} { + if bytes.Contains(traffic, []byte(secret)) { + t.Fatalf("relay read %q", secret) + } + } +} + +func rawTunnel(t *testing.T, r *rig, key *ecdh.PrivateKey) *pair.Tunnel { + t.Helper() + name, err := taskdoor.DoorName(r.id, r.b.ID()) + if err != nil { + t.Fatal(err) + } + stream, err := relay.Dial(context.Background(), r.service, name) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = stream.Close() }) + target, err := taskdoor.DoorKey(r.id, r.b.ID()) + if err != nil { + t.Fatal(err) + } + tunnel, err := pair.ConnectWithKey(stream, target.PublicKey().Bytes(), key) + if err != nil { + t.Fatal(err) + } + return tunnel +} +func rawSend(t *testing.T, w io.Writer, id string, payload any) { + t.Helper() + frame, err := taskdoor.NewFrame(id, payload) + if err != nil { + t.Fatal(err) + } + if err := taskdoor.WriteFrame(w, frame); err != nil { + t.Fatal(err) + } +} +func rawReceive(t *testing.T, r io.Reader, kind string) taskdoor.Frame { + t.Helper() + type result struct { + f taskdoor.Frame + err error + } + done := make(chan result, 1) + go func() { f, err := taskdoor.ReadFrame(r); done <- result{f, err} }() + select { + case result := <-done: + if result.err != nil { + t.Fatal(result.err) + } + if result.f.Type != kind { + t.Fatalf("frame %s, want %s", result.f.Type, kind) + } + return result.f + case <-time.After(5 * time.Second): + t.Fatalf("no raw %s frame", kind) + } + return taskdoor.Frame{} +} +func rawHello(t *testing.T, tunnel *pair.Tunnel, dev identity.Dev) { + t.Helper() + rawSend(t, tunnel, "", taskdoor.Hello{V: 1, Cert: dev.Cert, Sig: dev.Sign(tunnel.ChannelBinding())}) + rawReceive(t, tunnel, "hello") +} + +// Contract 13's authentication boundary includes certificates from another +// identity, a device impersonating a different static, and old-channel proofs. +func TestAHelloFromAnotherIdentityIsRefused(t *testing.T) { + for _, kind := range []string{"another identity", "another static", "another channel"} { + t.Run(kind, func(t *testing.T) { + r := newRig(t) + r.serve(r.config()) + id, dev := r.id, r.a + if kind == "another identity" { + var err error + id, err = identity.Mint() + if err != nil { + t.Fatal(err) + } + dev, err = identity.NewDevice(id) + if err != nil { + t.Fatal(err) + } + } + key, err := taskdoor.DoorKey(id, dev.ID()) + if err != nil { + t.Fatal(err) + } + if kind == "another static" { + key, err = taskdoor.DoorKey(r.id, r.b.ID()) + if err != nil { + t.Fatal(err) + } + } + tunnel := rawTunnel(t, r, key) + binding := tunnel.ChannelBinding() + if kind == "another channel" { + binding = bytes.Repeat([]byte{1}, len(binding)) + } + rawSend(t, tunnel, "", taskdoor.Hello{V: 1, Cert: dev.Cert, Sig: dev.Sign(binding)}) + var refused taskdoor.Refused + decode(t, rawReceive(t, tunnel, "refused"), &refused) + if refused.Reason != "could not confirm this computer" { + t.Fatalf("authentication failure = %q", refused.Reason) + } + if r.executor.calls.Load() != 0 { + t.Fatal("unauthorized hello started work") + } + }) + } +} + +// Contract 8 requires distinct actionable errors for a different build and a +// valid certificate that belongs to a computer other than the selected target. +func TestTheChosenComputerAndWireVersionAreChecked(t *testing.T) { + for _, kind := range []string{"wrong computer", "wrong version", "wrong signature"} { + t.Run(kind, func(t *testing.T) { + r := newRig(t) + key, err := taskdoor.DoorKey(r.id, r.b.ID()) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + registration, err := relay.Register(ctx, r.service, key) + if err != nil { + t.Fatal(err) + } + defer registration.Close() + done := make(chan struct{}) + go func() { + defer close(done) + stream, err := registration.Accept() + if err != nil { + return + } + defer stream.Close() + tunnel, err := pair.AcceptWithKey(stream, key) + if err != nil { + return + } + if _, err := taskdoor.ReadFrame(tunnel); err != nil { + return + } + dev := r.b + if kind == "wrong computer" { + dev = r.a + } + hello := taskdoor.Hello{V: 1, Cert: dev.Cert, Sig: dev.Sign(tunnel.ChannelBinding())} + if kind == "wrong version" { + hello.V = 2 + } + if kind == "wrong signature" { + hello.Sig = bytes.Repeat([]byte{1}, 64) + } + frame, err := taskdoor.NewFrame("", hello) + if err == nil { + _ = taskdoor.WriteFrame(tunnel, frame) + } + _, _ = taskdoor.ReadFrame(tunnel) + }() + c, err := taskdoor.Dial(ctx, r.service, r.id, r.a, r.b.ID(), "chosen computer") + if c != nil { + _ = c.Close() + } + want := "could not confirm this is chosen computer" + if kind == "wrong version" { + want = "chosen computer runs a different codeaf that cannot take this task — update both" + } + if err == nil || err.Error() != want { + t.Fatalf("heard %v, want %s", err, want) + } + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("refused peer stayed open") + } + }) + } +} + +// Contract 14's wire boundary caps memory before reading a claimed payload. +func TestFramesRefuseAnOversizedLengthBeforeReadingItsBody(t *testing.T) { + raw := binary.BigEndian.AppendUint32(nil, 1<<20+1) + if _, err := taskdoor.ReadFrame(bytes.NewReader(raw)); err == nil || !strings.Contains(err.Error(), "limit") { + t.Fatalf("oversized header returned %v", err) + } + _, err := taskdoor.NewFrame(attempt(1), taskdoor.Phase{Words: strings.Repeat("x", 1<<20)}) + if err == nil { + t.Fatal("oversized outgoing frame accepted") + } +} + +func (tap *wireTap) drop() { + tap.mu.Lock() + connections := tap.connections + tap.connections = nil + tap.mu.Unlock() + for _, connection := range connections { + _ = connection.Close() + } +} + +// Contract 9's carrier loss must leave the executor running while the shared +// registration loop emits one lost/back pair and reopens the same task door. +func TestATaskDoorComesBackWithoutRestartingItsAttempt(t *testing.T) { + r := newRig(t) + var unavailable atomic.Bool + service := &relay.Server{} + front := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + if unavailable.Load() { + http.Error(w, "temporarily unavailable", http.StatusServiceUnavailable) + return + } + service.ServeHTTP(w, request) + })) + t.Cleanup(front.Close) + tap := tapRelay(t, front.URL) + r.service = tap.address + finish := make(chan struct{}) + r.executor.run = func(ctx context.Context, a *taskdoor.Attempt, j taskdoor.Journal) error { + if err := j.Append(taskdoor.Phase{Words: "running"}); err != nil { + return err + } + select { + case <-finish: + return j.Append(taskdoor.Finished{Outcome: "done", Report: "continued through the outage"}) + case <-ctx.Done(): + return ctx.Err() + } + } + lines := make(chan string, 32) + config := r.config() + config.Say = func(line string) { lines <- line } + r.serve(config) + c := r.dial() + id := attempt(40) + send(t, c, id, request()) + receive(t, c, "started") + send(t, c, id, taskdoor.Watch{}) + receive(t, c, "phase") + unavailable.Store(true) + tap.drop() + name, err := taskdoor.DoorName(r.id, r.b.ID()) + if err != nil { + t.Fatal(err) + } + lost, back := 0, 0 + deadline := time.NewTimer(5 * time.Second) + defer deadline.Stop() + for back == 0 { + select { + case line := <-lines: + if line == pair.LostSyncLine { + lost++ + unavailable.Store(false) + } + if line == pair.BackLine(name) { + back++ + } + case <-deadline.C: + t.Fatal("task door did not say it came back") + } + } + if lost != 1 { + t.Fatalf("outage emitted %d lost lines", lost) + } + _ = c.Close() + c = r.dial() + send(t, c, id, taskdoor.Watch{From: 1}) + close(finish) + frame := receive(t, c, "finished") + if frame.Seq != 2 { + t.Fatalf("resumed finish seq = %d", frame.Seq) + } + if r.executor.calls.Load() != 1 || r.executor.adopts.Load() != 0 { + t.Fatalf("outage ran/adopted %d/%d", r.executor.calls.Load(), r.executor.adopts.Load()) + } +} + +// Contract 8 bounds a registered but silent door as well as an absent one; +// the timeout covers Noise and hello together, not just connecting the relay. +func TestARegisteredDoorThatDoesNotAnswerHasTheServingSentence(t *testing.T) { + r := newRig(t) + key, err := taskdoor.DoorKey(r.id, r.b.ID()) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + registration, err := relay.Register(ctx, r.service, key) + if err != nil { + t.Fatal(err) + } + defer registration.Close() + done := make(chan struct{}) + go func() { + defer close(done) + stream, err := registration.Accept() + if err != nil { + return + } + defer stream.Close() + <-ctx.Done() + }() + since := time.Now() + conn, err := taskdoor.Dial(ctx, r.service, r.id, r.a, r.b.ID(), "work computer") + if conn != nil { + _ = conn.Close() + } + if err == nil || err.Error() != "work computer is not taking tasks right now — run codeaf serve on it" { + t.Fatalf("silent door heard %v", err) + } + // Scheduling adds a small observation margin to the ten-second I/O bound. + if elapsed := time.Since(since); elapsed > 11*time.Second { + t.Fatalf("silent dial took %s", elapsed) + } + cancel() + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("silent fixture did not close") + } +} diff --git a/internal/taskdoor/serve.go b/internal/taskdoor/serve.go new file mode 100644 index 0000000000..ae6641d920 --- /dev/null +++ b/internal/taskdoor/serve.go @@ -0,0 +1,328 @@ +package taskdoor + +import ( + "context" + "crypto/ecdh" + "errors" + "io" + "os" + "path/filepath" + "sync" + "time" + + "github.com/Agent-Field/codeaf/internal/devname" + "github.com/Agent-Field/codeaf/internal/filelock" + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/pair" + "github.com/Agent-Field/codeaf/internal/relay" +) + +type Config struct { + Service string + Identity identity.Identity + Device identity.Dev + Roster func(context.Context) (map[string]bool, error) + Root string + Executor Executor + Say func(string) + Clock Clock + // Home is the profile whose chosen computer name admission uses. Empty + // uses the configured codeaf home, independently of the attempt store root. + Home string +} + +// Serve holds this device's task door beside the separate machine door. Runs +// are children of Serve's lifetime, while watches are children of a connection; +// losing one watch therefore cannot stop the work it was following. +func Serve(ctx context.Context, config Config) error { + if config.Service == "" || config.Roster == nil || config.Executor == nil { + return errors.New("taskdoor: a door needs a relay, roster and executor") + } + if config.Device.Cert.Verify(config.Identity.PublicKey()) != nil { + return errors.New("taskdoor: this device does not belong to the identity") + } + if config.Root == "" { + return errors.New("taskdoor: an attempt store folder is required") + } + if err := os.MkdirAll(config.Root, 0o700); err != nil { + return err + } + lock, err := os.OpenFile(filepath.Join(config.Root, ".lock"), os.O_CREATE|os.O_RDWR, 0o600) + if err != nil { + return err + } + defer lock.Close() + if err := filelock.Lock(lock, true, true); err != nil { + return errors.New("this computer is already taking tasks") + } + defer filelock.Unlock(lock) + clock := clockOrWall(config.Clock) + store, err := openAttempts(config.Root, clock) + if err != nil { + return err + } + key, err := DoorKey(config.Identity, config.Device.ID()) + if err != nil { + return err + } + if config.Home == "" { + config.Home = home.Dir() + } + server := &door{config: config, store: store, clock: clock, name: devname.Name(config.Home), roster: &rosterCache{fetch: config.Roster, clock: clock}} + runCtx, cancel := context.WithCancel(ctx) + defer server.workers.Wait() + // Defer order matters: workers must see cancellation before Serve waits, + // including when the registration fails for a reason other than shutdown. + defer cancel() + for _, a := range store.running() { + server.execute(runCtx, a, true) + } + announced := false + return (&pair.RegistrationLoop{ + Service: config.Service, Key: key, Say: config.Say, Now: clock.Now, + Wait: func(ctx context.Context, d time.Duration) error { + ticker := clock.NewTicker(d) + defer ticker.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-ticker.C(): + return nil + } + }, + Ready: func(*relay.Registration) { + if !announced { + announced = true + server.say(TakingTasksLine) + } + }, + Hold: func(ctx context.Context, r *relay.Registration) { server.hold(runCtx, r, key) }, + }).Run(ctx) +} + +type door struct { + config Config + store *attemptStore + roster *rosterCache + clock Clock + name string + workers sync.WaitGroup +} + +func (d *door) say(line string) { + if d.config.Say != nil { + d.config.Say(line) + } +} + +func (d *door) hold(ctx context.Context, registration *relay.Registration, key *ecdh.PrivateKey) { + stop := context.AfterFunc(ctx, func() { _ = registration.Close() }) + defer stop() + var connections sync.WaitGroup + defer connections.Wait() + defer registration.Close() + for { + stream, err := registration.Accept() + if err != nil { + return + } + connections.Add(1) + go func() { defer connections.Done(); d.answer(ctx, stream, key) }() + } +} +func (d *door) answer(ctx context.Context, stream io.ReadWriteCloser, key *ecdh.PrivateKey) { + defer stream.Close() + setDeadline(stream, time.Now().Add(pair.HandshakeWithin)) + handshakeCtx, endHandshake := context.WithTimeout(ctx, pair.HandshakeWithin) + defer endHandshake() + stop := context.AfterFunc(ctx, func() { _ = stream.Close() }) + defer stop() + tunnel, err := pair.AcceptWithKey(stream, key) + if err != nil { + return + } + if err := acceptHello(handshakeCtx, tunnel, d.config.Identity, d.config.Device, d.roster, d.name); err != nil { + return + } + endHandshake() + setDeadline(stream, time.Time{}) + conn := newConn(ctx, tunnel, d.clock) + var watch *doorWatch + // The connection closes before waiting for watches, so a blocked event + // write and a waiting replay are both released on every return path. + defer func() { _ = conn.Close(); watch.close() }() + for { + frame, err := conn.Receive() + if err != nil { + return + } + reply := func(payload any) error { return conn.Send(frame.Attempt, payload) } + switch frame.Type { + case "start": + var request Start + if err := frame.Decode(&request); err != nil { + _ = reply(Refused{Reason: "that task request is not readable"}) + continue + } + a, fresh, err := d.store.admit(frame.Attempt, request, Busy(d.name)) + if err != nil { + if reply(Refused{Reason: err.Error()}) != nil { + return + } + continue + } + // Run is launched after the durable admission even if the origin drops + // before it can hear started. Retrying then finds the same attempt. + if fresh { + d.execute(ctx, a, false) + } + if reply(Started{State: a.State()}) != nil { + return + } + case "watch": + var request Watch + if frame.Decode(&request) != nil { + return + } + a := d.store.get(frame.Attempt) + if a == nil { + if reply(Unknown{}) != nil { + return + } + continue + } + watch.close() + conn.startWatch() + watch = d.startWatch(ctx, conn, a, request.From) + case "stop": + if d.store.get(frame.Attempt) == nil { + if reply(Unknown{}) != nil { + return + } + continue + } + if err := d.config.Executor.Stop(frame.Attempt); err != nil { + if reply(Refused{Reason: err.Error()}) != nil { + return + } + continue + } + if reply(Stopping{}) != nil { + return + } + case "ack": + var ack Ack + if frame.Decode(&ack) != nil || (ack.Disposition != "landed" && ack.Disposition != "abandoned") { + if reply(Refused{Reason: "choose landed or abandoned for this task"}) != nil { + return + } + continue + } + if a := d.store.get(frame.Attempt); a != nil && a.State().Phase == "running" { + if err := d.config.Executor.Stop(frame.Attempt); err != nil { + if reply(Refused{Reason: err.Error()}) != nil { + return + } + continue + } + } + watch.close() + watch = nil + conn.watching.Store(false) + if err := d.store.forget(frame.Attempt); err != nil { + if reply(Refused{Reason: err.Error()}) != nil { + return + } + continue + } + if reply(Acked{}) != nil { + return + } + default: + return + } + } +} +func (d *door) execute(ctx context.Context, a *Attempt, adopt bool) { + d.workers.Add(1) + go func() { + defer d.workers.Done() + defer d.store.released(a) + journal := attemptJournal{a: a} + var err error + if adopt { + err = d.config.Executor.Adopt(ctx, a, journal) + } else { + err = d.config.Executor.Run(ctx, a, journal) + } + if ctx.Err() != nil { + return + } + if a.State().Phase != "running" { + return + } + report := "the task ended without a result" + if err != nil { + report = err.Error() + } + outcome := "incomplete" + if adopt { + outcome = "interrupted" + } + if err := journal.Append(Finished{Outcome: outcome, Report: report}); err != nil && !errors.Is(err, os.ErrNotExist) { + d.say("could not save the task result: " + err.Error()) + } + }() +} +func (d *door) watch(ctx context.Context, conn *Conn, a *Attempt, from uint64) { + for { + frames, changed, finished, err := a.after(from) + if err != nil { + _ = conn.Send(a.ID, Unknown{}) + return + } + for _, frame := range frames { + select { + case <-ctx.Done(): + return + default: + } + if err := conn.sendFrame(frame); err != nil { + return + } + from = frame.Seq + } + if finished { + conn.watching.Store(false) + return + } + select { + case <-ctx.Done(): + return + case <-conn.done: + return + case <-changed: + } + } +} + +// doorWatch joins a replaced watch before opening its successor, so two +// replay goroutines can never interleave events on one connection. +type doorWatch struct { + cancel context.CancelFunc + done chan struct{} +} + +func (w *doorWatch) close() { + if w != nil { + w.cancel() + <-w.done + } +} +func (d *door) startWatch(ctx context.Context, conn *Conn, a *Attempt, from uint64) *doorWatch { + ctx, cancel := context.WithCancel(ctx) + watch := &doorWatch{cancel: cancel, done: make(chan struct{})} + go func() { defer close(watch.done); defer cancel(); d.watch(ctx, conn, a, from) }() + return watch +} From 7372fe9973f97f28f5d692eabab4907671959406 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Fri, 30 Oct 2026 17:42:50 -0400 Subject: [PATCH 02/34] taskdoor: send a task copy out as a delta and bring its change home A task's local copy is sealed into a cell of its own, never the chat's, and only the objects the relay's store lacks are uploaded, so a second send of a 10,000-file folder with one change uploads about nine kilobytes. The other computer restores the copy with its Git history, and its finished folder comes back the same way; this computer refuses the result if its own copy changed while the work was away, keeping both. Task-only uploads ask the store what it holds even when small, because no directory head says what was published. Co-Authored-By: Claude Opus 5.5 --- internal/cellsync/held.go | 2 +- internal/cellsync/publish.go | 4 + internal/taskcopy/copy.go | 173 +++++++++++++++ internal/taskdoor/copy.go | 387 +++++++++++++++++++++++++++++++++ internal/taskdoor/copy_test.go | 305 ++++++++++++++++++++++++++ internal/taskdoor/frames.go | 13 +- 6 files changed, 879 insertions(+), 5 deletions(-) create mode 100644 internal/taskcopy/copy.go create mode 100644 internal/taskdoor/copy.go create mode 100644 internal/taskdoor/copy_test.go diff --git a/internal/cellsync/held.go b/internal/cellsync/held.go index de031f0064..b55be0e626 100644 --- a/internal/cellsync/held.go +++ b/internal/cellsync/held.go @@ -24,7 +24,7 @@ const askFloor = 64 << 10 // fails the export is returned whole, and the puts that follow report the real // error. func (p *Publisher) skipHeld(ctx context.Context, c cell.Cell, ex Export) Export { - if ex.Bytes < askFloor { + if ex.Bytes < askFloor && !p.CheckHeld { return ex } held, err := p.holdings(ctx, ex.Frames) diff --git a/internal/cellsync/publish.go b/internal/cellsync/publish.go index a33587db66..1f752eecf2 100644 --- a/internal/cellsync/publish.go +++ b/internal/cellsync/publish.go @@ -18,6 +18,10 @@ var ErrSuperseded = fmt.Errorf("cellsync: another device continued this chat: %w // Publisher makes turns durable: objects into the store first, the head in the // directory last. Dir is already bound to this device. type Publisher struct { + // CheckHeld makes even a small independent upload ask which objects the + // relay lacks. Task-only cells use it because they have no directory head + // from which to assume what another sender has already published. + CheckHeld bool // WarmFrameHints opts into the bounded existing-frame prototype. WarmFrameHints bool Engine Engine diff --git a/internal/taskcopy/copy.go b/internal/taskcopy/copy.go new file mode 100644 index 0000000000..6f34c6aa0a --- /dev/null +++ b/internal/taskcopy/copy.go @@ -0,0 +1,173 @@ +package taskcopy + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + + "github.com/Agent-Field/codeaf/internal/fsreplace" +) + +// CaptureCopy uses the continue-here carrier for one task without needing the +// chat or the source project on the receiving computer. The full reachable +// history makes a linked worktree independent of its original repository. +func CaptureCopy(tree, carrier string) error { + if err := carryWithHistory(tree, carrier, true); err != nil { + return err + } + dirs, err := captureWorkspaceDirectories(tree) + if err != nil { + return err + } + return writeWorkspaceJSON(filepath.Join(carrier, "directories.json"), dirs) +} + +// ImportCopyHistory runs before the last conflict check because a large fetch +// must not provide a window in which a local edit can be overwritten. +func ImportCopyHistory(original, prepared string) error { + info, err := os.Lstat(filepath.Join(original, ".git")) + if err != nil { + return err + } + if info.IsDir() { + return nil + } + if !info.Mode().IsRegular() { + return errors.New("the task copy's repository link is not a file") + } + _, err = git(original, "-c", "core.hooksPath=/dev/null", "fetch", "--quiet", "--no-tags", "--no-write-fetch-head", prepared, "HEAD") + return err +} + +// BindLinkedResult preserves the origin's worktree registration after the +// returned commits have been imported. Normal landing expects +// that task branch to live in the ground repository on this copy rung. +// The caller must run undo if it cannot install prepared over original. +func BindLinkedResult(original, prepared string) (undo func() error, err error) { + info, err := os.Lstat(filepath.Join(original, ".git")) + if err != nil { + return nil, err + } + if info.IsDir() { + return func() error { return nil }, nil + } + if !info.Mode().IsRegular() { + return nil, errors.New("the task copy's repository link is not a file") + } + pointer, err := os.ReadFile(filepath.Join(original, ".git")) + if err != nil { + return nil, err + } + oldHead, err := git(original, "rev-parse", "HEAD") + if err != nil { + return nil, err + } + newHead, err := git(prepared, "rev-parse", "HEAD") + if err != nil { + return nil, err + } + gitDir, err := git(original, "rev-parse", "--absolute-git-dir") + if err != nil { + return nil, err + } + index := filepath.Join(strings.TrimSpace(gitDir), "index") + oldIndex, err := os.ReadFile(index) + if err != nil { + return nil, err + } + if err := os.RemoveAll(filepath.Join(prepared, ".git")); err != nil { + return nil, err + } + if err := os.WriteFile(filepath.Join(prepared, ".git"), pointer, info.Mode().Perm()); err != nil { + return nil, err + } + oldHead, newHead = strings.TrimSpace(oldHead), strings.TrimSpace(newHead) + if _, err := git(original, "update-ref", "HEAD", newHead, oldHead); err != nil { + return nil, err + } + undo = func() error { + if _, err := git(original, "update-ref", "HEAD", oldHead, newHead); err != nil { + return err + } + return replaceCopyIndex(index, oldIndex) + } + // Git builds the receiving index itself because a copied split index + // could refer to shared-index files owned by the disposable repository. + if _, err := git(original, "read-tree", "--reset", newHead); err != nil { + return nil, errors.Join(err, undo()) + } + return undo, nil +} + +func replaceCopyIndex(path string, raw []byte) error { + file, err := os.CreateTemp(filepath.Dir(path), ".task-index-*") + if err != nil { + return err + } + defer os.Remove(file.Name()) + _, writeErr := file.Write(raw) + syncErr := file.Sync() + closeErr := file.Close() + if err := errors.Join(writeErr, syncErr, closeErr); err != nil { + return err + } + return fsreplace.Rename(file.Name(), path) +} + +// RestoreCopy validates the complete carrier before creating an independent +// repository. Its destination must be absent or empty; replacing an existing +// copy is the caller's responsibility after its conflict check. +func RestoreCopy(carrier, destination string) error { + rec, err := readWorkspaceRecord(carrier) + if err != nil { + return err + } + if !rec.FullHistory { + return errors.New("the task copy is missing its independent history") + } + if err := validateWorkspaceRecord(carrier, rec); err != nil { + return err + } + if err := screenFullHistory(filepath.Join(carrier, bundleName)); err != nil { + return err + } + var root workspaceRoot + root.DirectoryVersion = 2 + if rec.Symlinks == nil { + root.DirectoryVersion = 1 + } + raw, err := readWorkspaceFile(filepath.Join(carrier, "directories.json"), workspaceManifestLimit) + if err != nil { + return err + } + if err := json.Unmarshal(raw, &root.Directories); err != nil { + return err + } + if _, err := validateWorkspaceDirectories(root, rec); err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(destination), 0700); err != nil { + return err + } + if err := restoreStandaloneCopy("", destination, carrier, rec); err != nil { + return err + } + if err := validateWorkspaceOverlay(destination, carrier, rec); err != nil { + return err + } + if err := prepareWorkspaceDirectories(destination, root.Directories); err != nil { + return err + } + if err := errors.Join(applyEOL(destination, rec.EOL), overlay(filepath.Join(carrier, filesDir), destination, rec)); err != nil { + return err + } + if err := applyModes(destination, rec.Modes); err != nil { + return err + } + if err := restorePortableBaseline(destination, rec); err != nil { + return err + } + return finishWorkspaceDirectories(destination, root.Directories) +} diff --git a/internal/taskdoor/copy.go b/internal/taskdoor/copy.go new file mode 100644 index 0000000000..191f164d52 --- /dev/null +++ b/internal/taskdoor/copy.go @@ -0,0 +1,387 @@ +package taskdoor + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "time" + + "github.com/Agent-Field/codeaf/internal/blobstore" + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/cellstore" + "github.com/Agent-Field/codeaf/internal/cellsync" + "github.com/Agent-Field/codeaf/internal/filelock" + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/taskcopy" +) + +const copyCarrier = cell.StateDir + "/task-copy" + +// Input retains the origin's conflict fingerprint locally; only Head travels +// on the wire. Result uses the same task-only cell after the work finishes. +type Input = Head +type Result = Head + +// CopyDeps keeps task transport separate from chat heads and leases. Engine's +// shared object store may be the profile's ordinary store for identity-wide +// deduplication; Root holds only task cells, locks and transfer scratch. +type CopyDeps struct { + Root string + Service string + Identity identity.Identity + Device identity.Dev + Engine cellstore.Engine + Store blobstore.Store + Measure func(CopyMeasure) +} + +type CopyMeasure struct { + Seal, Upload, Fetch, Restore time.Duration + Bytes int64 +} + +func (d CopyDeps) engine() cellstore.Engine { + e := d.Engine + e.Workspace, e.WorkspaceOf, e.Compose = "", nil, nil + e.Identity = cellstore.Identity{Device: d.Device.ID()} + return e +} +func (d CopyDeps) sync() cellstore.SyncEngine { + s := d.engine().Sync(cellstore.SyncKeys{CellKey: d.Identity.CellKey(), Dedup: d.Identity.DedupSecret()}, cellstore.LedgerName(d.Service, d.Identity.ID())) + // Small source files need bounded frames too: the engine's default can + // exceed the blob wire's header bound before reaching its payload bound. + s.MaxFrame = 64 << 10 + return s +} +func (d CopyDeps) cell(id string) (cell.Cell, error) { + if !cell.ValidID(id) { + return cell.Cell{}, errors.New("the task copy's cell is not readable") + } + return cell.Cell{ID: id, Root: filepath.Join(d.Root, "cells", id)}, nil +} +func (d CopyDeps) measure(m CopyMeasure) { + if d.Measure != nil { + d.Measure(m) + } +} + +func (d CopyDeps) lock(ctx context.Context, name string) (func(), error) { + if d.Root == "" || d.Store == nil || d.Service == "" { + return nil, errors.New("task copies need private storage and a relay blob store") + } + dir := filepath.Join(d.Root, "locks") + if err := os.MkdirAll(dir, 0700); err != nil { + return nil, err + } + f, err := os.OpenFile(filepath.Join(dir, name), os.O_CREATE|os.O_RDWR, 0600) + if err != nil { + return nil, err + } + for { + if err := ctx.Err(); err != nil { + f.Close() + return nil, err + } + if err := filelock.Lock(f, true, true); err == nil { + return func() { _ = filelock.Unlock(f); _ = f.Close() }, nil + } else if !filelock.IsBusy(err) { + _ = f.Close() + return nil, err + } + select { + case <-ctx.Done(): + f.Close() + return nil, ctx.Err() + case <-time.After(20 * time.Millisecond): + } + } +} + +// SendCopy seals a carrier in its own cell. Upload deliberately has no +// directory client: this road cannot publish a chat head or acquire a lease. +func SendCopy(ctx context.Context, d CopyDeps, copyDir string) (Input, error) { + path, err := allowedCopy(copyDir) + if err != nil { + return Input{}, err + } + sum := sha256.Sum256([]byte(path)) + key := hex.EncodeToString(sum[:]) + unlock, err := d.lock(ctx, "source-"+key) + if err != nil { + return Input{}, err + } + defer unlock() + ref := filepath.Join(d.Root, "sources", key+".json") + var previous Input + err = readJSON(ref, &previous) + var c cell.Cell + if errors.Is(err, os.ErrNotExist) { + c, err = cell.CreateIn(filepath.Join(d.Root, "cells"), cell.Options{Class: cell.Sandboxed, KeyID: d.Identity.CellKeyID()}) + } else if err == nil { + c, err = d.cell(previous.Cell) + } + if err != nil { + return Input{}, err + } + out, err := sendCopy(ctx, d, path, c, "") + if err != nil { + return Input{}, err + } + if err := os.MkdirAll(filepath.Dir(ref), 0700); err != nil { + return Input{}, err + } + return out, atomicJSON(ref, out) +} + +func sendCopy(ctx context.Context, d CopyDeps, path string, c cell.Cell, baseHead string) (Input, error) { + unlock, err := d.lock(ctx, c.ID) + if err != nil { + return Input{}, err + } + defer unlock() + if baseHead != "" { + // A retry or another attempt using the same input must still make + // this result a delta from that input, never from its sibling's work. + if err := d.sync().Materialize(ctx, c, baseHead); err != nil { + return Input{}, err + } + } + c, err = cell.OpenAt(c.Root, c.ID) + if err != nil { + return Input{}, err + } + before, err := copyFingerprint(ctx, path) + if err != nil { + return Input{}, err + } + start := time.Now() + if err := taskcopy.CaptureCopy(path, filepath.Join(c.Root, copyCarrier)); err != nil { + return Input{}, err + } + after, err := copyFingerprint(ctx, path) + if err != nil { + return Input{}, err + } + if before != after { + return Input{}, errors.New("the task copy changed while it was being sent; try again") + } + sealed, err := d.engine().Seal(ctx, c, cellstore.TurnInfo{Trigger: cellstore.AgentRun}) + if err != nil { + return Input{}, err + } + m := CopyMeasure{Seal: time.Since(start)} + start = time.Now() + publisher := cellsync.Publisher{Engine: d.sync(), Store: d.Store, CheckHeld: true} + exported, err := publisher.Upload(ctx, c, sealed.Turn.ID) + if err != nil { + return Input{}, err + } + m.Upload = time.Since(start) + for _, frame := range exported.Frames { + m.Bytes += frame.Bytes + } + d.measure(m) + return Input{Head: sealed.Turn.ID, Cell: c.ID, Fingerprint: before}, nil +} + +// ReceiveCopy restores only the task carrier; capture metadata stays beside +// the work folder so landing cannot mistake it for the task's changes. +func ReceiveCopy(ctx context.Context, d CopyDeps, in Input, dest string) error { + return receiveCopy(ctx, d, in, dest, "") +} + +func receiveCopy(ctx context.Context, d CopyDeps, in Head, dest, fingerprint string) error { + path, err := allowedCopy(dest) + if err != nil { + return err + } + c, err := d.cell(in.Cell) + if err != nil { + return err + } + if raw, err := hex.DecodeString(in.Head); err != nil || len(raw) != sha256.Size { + return errors.New("the task copy's head is not readable") + } + unlock, err := d.lock(ctx, c.ID) + if err != nil { + return err + } + defer unlock() + if fingerprint != "" { + if err := unchangedCopy(ctx, path, fingerprint); err != nil { + return err + } + } + if err := os.MkdirAll(c.Root, 0700); err != nil { + return err + } + start := time.Now() + syncer := d.sync() + fetcher := cellsync.Fetcher{Engine: syncer, Store: d.Store, Inbox: syncer.Inbox} + if err := fetcher.Complete(ctx, c, in.Head); err != nil { + return err + } + m := CopyMeasure{Fetch: time.Since(start)} + start = time.Now() + if err := syncer.Materialize(ctx, c, in.Head); err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + return err + } + stage, err := os.MkdirTemp(filepath.Dir(path), ".task-arrival-") + if err != nil { + return err + } + cleanStage := true + defer func() { + if cleanStage { + _ = os.RemoveAll(stage) + } + }() + prepared := filepath.Join(stage, "copy") + if err := taskcopy.RestoreCopy(filepath.Join(c.Root, copyCarrier), prepared); err != nil { + return err + } + if err := ctx.Err(); err != nil { + return err + } + if fingerprint != "" { + if err := taskcopy.ImportCopyHistory(path, prepared); err != nil { + return err + } + } + if fingerprint != "" { + // Recheck after the slow fetch and restore, immediately before the swap. + if err := unchangedCopy(ctx, path, fingerprint); err != nil { + return err + } + } else if entries, err := os.ReadDir(path); err == nil && len(entries) > 0 { + return errors.New("the task's destination already contains files") + } else if err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + backup := filepath.Join(stage, "previous") + undo := func() error { return nil } + if fingerprint != "" { + undo, err = taskcopy.BindLinkedResult(path, prepared) + if err != nil { + return err + } + } + if err := os.Rename(path, backup); err != nil && !errors.Is(err, os.ErrNotExist) { + return errors.Join(err, undo()) + } + if err := os.Rename(prepared, path); err != nil { + if restoreErr := os.Rename(backup, path); restoreErr != nil { + // A failed rollback must keep the original copy, even when its + // parent has become unwritable while the result was arriving. + cleanStage = false + return fmt.Errorf("could not put the result in place; the earlier copy is kept at %s: %w", backup, errors.Join(err, restoreErr)) + } + return errors.Join(err, undo()) + } + m.Restore = time.Since(start) + d.measure(m) + return nil +} + +func SendResult(ctx context.Context, d CopyDeps, workDir string, in Input) (Result, error) { + path, err := allowedCopy(workDir) + if err != nil { + return Result{}, err + } + c, err := d.cell(in.Cell) + if err != nil { + return Result{}, err + } + return sendCopy(ctx, d, path, c, in.Head) +} + +var ErrCopyChanged = errors.New("the task copy changed here while the other computer worked; both copies were kept") + +func ReceiveResult(ctx context.Context, d CopyDeps, result Result, localCopyDir, sentFingerprint string) error { + if sentFingerprint == "" { + return errors.New("the sent task copy's fingerprint is missing") + } + return receiveCopy(ctx, d, result, localCopyDir, sentFingerprint) +} +func unchangedCopy(ctx context.Context, path, fingerprint string) error { + now, err := copyFingerprint(ctx, path) + if err != nil { + return err + } + if now != fingerprint { + return ErrCopyChanged + } + return nil +} + +func allowedCopy(path string) (string, error) { + absolute, err := filepath.Abs(path) + if err != nil { + return "", err + } + // Resolve existing parents too, so a missing destination through a symlink + // cannot bypass the same home-folder refusal the capture engine applies. + parent := absolute + for { + resolved, err := filepath.EvalSymlinks(parent) + if err == nil { + rel, _ := filepath.Rel(parent, absolute) + absolute = filepath.Join(resolved, rel) + break + } + if !errors.Is(err, os.ErrNotExist) { + return "", err + } + next := filepath.Dir(parent) + if next == parent { + return "", err + } + parent = next + } + house, err := os.UserHomeDir() + if err != nil { + return "", err + } + if resolved, err := filepath.EvalSymlinks(house); err == nil { + house = resolved + } + rel, err := filepath.Rel(absolute, house) + if filepath.Dir(absolute) == absolute || err == nil && filepath.IsLocal(rel) { + return "", errors.New("a home folder or a folder above it cannot be sent as a task copy") + } + return absolute, nil +} + +func copyFingerprint(ctx context.Context, root string) (string, error) { + hash := sha256.New() + for _, args := range [][]string{{"rev-parse", "HEAD"}, {"symbolic-ref", "-q", "HEAD"}, {"diff", "--cached", "--binary"}} { + cmd := exec.CommandContext(ctx, "git", append([]string{"-C", root}, args...)...) + out, err := cmd.Output() + if err != nil && args[0] != "symbolic-ref" { + return "", err + } + fmt.Fprintf(hash, "%d:", len(out)) + _, _ = hash.Write(out) + } + if err := ctx.Err(); err != nil { + return "", err + } + state, err := taskcopy.InspectPortableState(root) + if err != nil { + return "", err + } + digest, err := state.Digest() + if err != nil { + return "", err + } + _, _ = hash.Write([]byte(digest)) + return hex.EncodeToString(hash.Sum(nil)), ctx.Err() +} diff --git a/internal/taskdoor/copy_test.go b/internal/taskdoor/copy_test.go new file mode 100644 index 0000000000..ccd92a015c --- /dev/null +++ b/internal/taskdoor/copy_test.go @@ -0,0 +1,305 @@ +//go:build linux || darwin + +package taskdoor_test + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/blobstore" + "github.com/Agent-Field/codeaf/internal/cellstore" + "github.com/Agent-Field/codeaf/internal/furrow" + "github.com/Agent-Field/codeaf/internal/taskdoor" +) + +func copyHomes(t *testing.T) (taskdoor.CopyDeps, taskdoor.CopyDeps) { + t.Helper() + r := newRig(t) + bin, err := furrow.ResolveOwned() + if err != nil { + t.Fatal(err) + } + mem := blobstore.NewMemory() + srv := httptest.NewServer(blobstore.Handler(func(*http.Request, []byte) (string, string, error) { + return r.id.ID(), r.a.ID(), nil + }, func(string) (blobstore.Store, error) { return mem, nil })) + t.Cleanup(srv.Close) + makeHome := func(devHome string) taskdoor.CopyDeps { + engine := cellstore.EngineFor("") + engine.DataRoot = filepath.Join(devHome, "stores") + engine.Binary = bin + // The fixture owns its daemon so no engine can outlive the test's store. + socketHome, err := os.MkdirTemp("", "rn-l1-") + if err != nil { + t.Fatal(err) + } + daemon := cellstore.Daemon{Binary: bin, Socket: filepath.Join(socketHome, "e.sock")} + engine.Transport = daemon + t.Cleanup(func() { _ = daemon.Stop(context.Background()); _ = os.RemoveAll(socketHome) }) + return taskdoor.CopyDeps{Root: filepath.Join(devHome, "copies"), Service: srv.URL, + Identity: r.id, Device: r.a, Engine: engine, Store: blobstore.NewHTTP(srv.URL, func(*http.Request, []byte) {}, srv.Client())} + } + a, b := makeHome(t.TempDir()), makeHome(t.TempDir()) + b.Device = r.b + return a, b +} + +func copyGit(t *testing.T, dir string, args ...string) string { + t.Helper() + cmd := exec.Command("git", append([]string{"-C", dir, "-c", "user.name=task test", "-c", "user.email=task@example.test"}, args...)...) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %v: %v: %s", args, err, out) + } + return strings.TrimSpace(string(out)) +} + +func copyFile(t *testing.T, dir, name, words string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(filepath.Join(dir, name)), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, name), []byte(words), 0640); err != nil { + t.Fatal(err) + } +} + +func copyRepo(t *testing.T) string { + t.Helper() + dir := t.TempDir() + copyGit(t, dir, "init", "-q", "-b", "task-copy") + copyFile(t, dir, "edit.txt", "before\n") + copyFile(t, dir, "delete.txt", "remove this\n") + copyFile(t, dir, ".gitignore", "ignored/\n") + copyGit(t, dir, "add", "--all") + copyGit(t, dir, "commit", "-qm", "baseline") + return dir +} + +// Contract 3: the real carrier, engine and blob wire preserve files and commits +// across two homes while the chat and its directory never enter the transfer. +func TestATaskCopyGoesOutAndComesBackWithItsEdits(t *testing.T) { + a, b := copyHomes(t) + local, work := copyRepo(t), filepath.Join(t.TempDir(), "work") + copyFile(t, local, "ignored/cache", "only here") + copyFile(t, local, "uncommitted.txt", "carried too") + if err := os.Symlink("edit.txt", filepath.Join(local, "link")); err != nil { + t.Fatal(err) + } + in, err := taskdoor.SendCopy(context.Background(), a, local) + if err != nil { + t.Fatal(err) + } + if err := taskdoor.ReceiveCopy(context.Background(), b, in, work); err != nil { + t.Fatal(err) + } + if got := copyGit(t, work, "rev-parse", "HEAD"); got != copyGit(t, local, "rev-parse", "HEAD") { + t.Fatal("HEAD did not travel") + } + if got := copyGit(t, work, "branch", "--show-current"); got != "task-copy" { + t.Fatal(got) + } + if info, err := os.Stat(filepath.Join(work, "uncommitted.txt")); err != nil || info.Mode().Perm() != 0640 { + t.Fatalf("mode: %v, %v", info, err) + } + if link, err := os.Readlink(filepath.Join(work, "link")); err != nil || link != "edit.txt" { + t.Fatalf("link: %s, %v", link, err) + } + if _, err := os.Stat(filepath.Join(work, "ignored")); !os.IsNotExist(err) { + t.Fatalf("ignored files traveled: %v", err) + } + copyFile(t, work, "edit.txt", "from B\n") + copyFile(t, work, "added.txt", "new on B\n") + if err := os.Remove(filepath.Join(work, "delete.txt")); err != nil { + t.Fatal(err) + } + copyGit(t, work, "add", "--all") + copyGit(t, work, "commit", "-qm", "finished on B") + result, err := taskdoor.SendResult(context.Background(), b, work, in) + if err != nil { + t.Fatal(err) + } + if err := taskdoor.ReceiveResult(context.Background(), a, result, local, in.Fingerprint); err != nil { + t.Fatal(err) + } + if got := copyGit(t, local, "rev-parse", "HEAD"); got != copyGit(t, work, "rev-parse", "HEAD") { + t.Fatal("B's commit did not come home") + } + for _, dir := range []string{local, work} { + for _, name := range []string{".cell", ".furrow", ".furrowpolicy", "delete.txt"} { + if _, err := os.Lstat(filepath.Join(dir, name)); !os.IsNotExist(err) { + t.Errorf("%s left %s: %v", dir, name, err) + } + } + for name, want := range map[string]string{"edit.txt": "from B\n", "added.txt": "new on B\n", "uncommitted.txt": "carried too"} { + got, err := os.ReadFile(filepath.Join(dir, name)) + if err != nil || string(got) != want { + t.Errorf("%s/%s = %q, %v", dir, name, got, err) + } + } + } +} + +// Contract 3: work done here while B runs cannot be overwritten by its result. +func TestAResultIsRefusedWhenTheCopyChangedHere(t *testing.T) { + a, b := copyHomes(t) + local, work := copyRepo(t), filepath.Join(t.TempDir(), "work") + in, err := taskdoor.SendCopy(context.Background(), a, local) + if err != nil { + t.Fatal(err) + } + if err := taskdoor.ReceiveCopy(context.Background(), b, in, work); err != nil { + t.Fatal(err) + } + copyFile(t, work, "edit.txt", "B's work") + result, err := taskdoor.SendResult(context.Background(), b, work, in) + if err != nil { + t.Fatal(err) + } + copyFile(t, local, "edit.txt", "changed here") + before := copyGit(t, local, "status", "--porcelain") + if err := taskdoor.ReceiveResult(context.Background(), a, result, local, in.Fingerprint); err == nil { + t.Fatal("changed copy accepted") + } + got, err := os.ReadFile(filepath.Join(local, "edit.txt")) + if err != nil || string(got) != "changed here" { + t.Fatalf("local work changed: %q, %v", got, err) + } + if after := copyGit(t, local, "status", "--porcelain"); after != before { + t.Fatalf("refusal changed copy: %s", after) + } +} + +// Contract 3 includes linked worktrees: normal landing reads the ground's +// shared task branch, so receiving a result must retain that registration. +func TestALinkedTaskCopyKeepsItsBranchForNormalLanding(t *testing.T) { + a, b := copyHomes(t) + ground := copyRepo(t) + local := filepath.Join(t.TempDir(), "task") + copyGit(t, ground, "worktree", "add", "-q", "-b", "task-linked", local, "HEAD") + in, err := taskdoor.SendCopy(t.Context(), a, local) + if err != nil { + t.Fatal(err) + } + work := filepath.Join(t.TempDir(), "work") + if err := taskdoor.ReceiveCopy(t.Context(), b, in, work); err != nil { + t.Fatal(err) + } + copyFile(t, work, "edit.txt", "the returned commit\n") + copyGit(t, work, "add", "--all") + copyGit(t, work, "commit", "-qm", "the remote task") + result, err := taskdoor.SendResult(t.Context(), b, work, in) + if err != nil { + t.Fatal(err) + } + if err := taskdoor.ReceiveResult(t.Context(), a, result, local, in.Fingerprint); err != nil { + t.Fatal(err) + } + if info, err := os.Stat(filepath.Join(local, ".git")); err != nil || !info.Mode().IsRegular() { + t.Fatalf("lost linked registration: %v, %v", info, err) + } + want := copyGit(t, work, "rev-parse", "HEAD") + if got := copyGit(t, ground, "rev-parse", "task-linked"); got != want { + t.Fatalf("landing branch=%s want %s", got, want) + } + if got := copyGit(t, local, "rev-parse", "HEAD"); got != want { + t.Fatalf("task copy=%s want %s", got, want) + } + if status := copyGit(t, local, "status", "--porcelain"); status != "" { + t.Fatalf("returned commit has spurious edits: %s", status) + } +} + +// Contract 3's refusal check follows the potentially slow history import, so +// an editor active during that import cannot lose its work to the result. +func TestAResultKeepsAnEditMadeWhileItsHistoryArrives(t *testing.T) { + a, b := copyHomes(t) + ground := copyRepo(t) + local := filepath.Join(t.TempDir(), "task") + copyGit(t, ground, "worktree", "add", "-q", "-b", "task-linked", local, "HEAD") + oldHead := copyGit(t, local, "rev-parse", "HEAD") + in, err := taskdoor.SendCopy(t.Context(), a, local) + if err != nil { + t.Fatal(err) + } + work := filepath.Join(t.TempDir(), "work") + if err := taskdoor.ReceiveCopy(t.Context(), b, in, work); err != nil { + t.Fatal(err) + } + copyFile(t, work, "edit.txt", "B's change") + copyGit(t, work, "add", "--all") + copyGit(t, work, "commit", "-qm", "remote work") + result, err := taskdoor.SendResult(t.Context(), b, work, in) + if err != nil { + t.Fatal(err) + } + realGit, err := exec.LookPath("git") + if err != nil { + t.Fatal(err) + } + wrapper := t.TempDir() + script := "#!/bin/sh\nfor arg in \"$@\"; do\n if [ \"$arg\" = fetch ]; then printf 'edited during import' > \"$CODEAF_TEST_EDIT_DURING_FETCH\"; fi\ndone\nexec \"$CODEAF_TEST_REAL_GIT\" \"$@\"\n" + if err := os.WriteFile(filepath.Join(wrapper, "git"), []byte(script), 0755); err != nil { + t.Fatal(err) + } + t.Setenv("CODEAF_TEST_REAL_GIT", realGit) + t.Setenv("CODEAF_TEST_EDIT_DURING_FETCH", filepath.Join(local, "edit.txt")) + t.Setenv("PATH", wrapper+string(os.PathListSeparator)+os.Getenv("PATH")) + if err := taskdoor.ReceiveResult(t.Context(), a, result, local, in.Fingerprint); !errors.Is(err, taskdoor.ErrCopyChanged) { + t.Fatalf("concurrent edit accepted: %v", err) + } + if got := copyGit(t, local, "rev-parse", "HEAD"); got != oldHead { + t.Fatal("refusal moved task branch") + } + if raw, err := os.ReadFile(filepath.Join(local, "edit.txt")); err != nil || string(raw) != "edited during import" { + t.Fatalf("local edit lost: %q, %v", raw, err) + } +} + +// Contract 15: a second send pays for the edited file and new structure only. +func TestASecondSendUploadsOnlyTheChange(t *testing.T) { + if testing.Short() { + t.Skip("10,000-file measurement") + } + started := time.Now() + ctx, cancel := context.WithTimeout(t.Context(), time.Minute) + defer cancel() + a, b := copyHomes(t) + local := copyRepo(t) + for n := 0; n < 10000; n++ { + copyFile(t, local, fmt.Sprintf("files/%05d.txt", n), fmt.Sprintf("project file %d\n", n)) + } + copyGit(t, local, "add", "--all") + copyGit(t, local, "commit", "-qm", "project files") + for n := 0; n < 2; n++ { + if n == 1 { + copyFile(t, local, "files/00001.txt", "one changed file\n") + } + var seal, upload, fetch, restore taskdoor.CopyMeasure + a.Measure = func(m taskdoor.CopyMeasure) { seal, upload = m, m } + b.Measure = func(m taskdoor.CopyMeasure) { fetch, restore = m, m } + in, err := taskdoor.SendCopy(ctx, a, local) + if err != nil { + t.Fatal(err) + } + if err := taskdoor.ReceiveCopy(ctx, b, in, filepath.Join(t.TempDir(), "work")); err != nil { + t.Fatal(err) + } + t.Logf("delta-measure: send=%d seal=%s upload=%s fetch=%s restore=%s bytes=%d", n+1, seal.Seal, upload.Upload, fetch.Fetch, restore.Restore, upload.Bytes) + if n == 1 && upload.Bytes >= 256<<10 { + t.Fatalf("second upload = %d bytes", upload.Bytes) + } + } + if elapsed := time.Since(started); elapsed >= time.Minute { + t.Fatalf("delta measurement exceeded one minute: %s", elapsed) + } +} diff --git a/internal/taskdoor/frames.go b/internal/taskdoor/frames.go index 172be8b5d0..4f7f99ee67 100644 --- a/internal/taskdoor/frames.go +++ b/internal/taskdoor/frames.go @@ -31,8 +31,9 @@ func (f Frame) Decode(into any) error { return json.Unmarshal(f.Payload, into) } // Head identifies a task-only cell, independently of the chat's own head. type Head struct { - Head string `json:"head"` - Cell string `json:"cell"` + Head string `json:"head"` + Cell string `json:"cell"` + Fingerprint string `json:"-"` } type Limits struct { USD float64 `json:"usd,omitempty"` @@ -66,7 +67,9 @@ type Watch struct { From uint64 `json:"from"` } type Step struct { - Step run.Step `json:"step"` + Step run.Step `json:"step"` + Source string `json:"source,omitempty"` + Offset int64 `json:"offset,omitempty"` } // SpendLine carries one charge, so the origin can attribute it once rather @@ -80,7 +83,9 @@ type SpendLine struct { USD float64 `json:"usd,omitempty"` } type Spend struct { - Line SpendLine `json:"line"` + Line SpendLine `json:"line"` + Source string `json:"source,omitempty"` + Offset int64 `json:"offset,omitempty"` } type Phase struct { Words string `json:"words"` From e0e2108b836a9153a23e00143650364c6c994b82 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Fri, 30 Oct 2026 17:42:50 -0400 Subject: [PATCH 03/34] serve: take tasks from paired computers `codeaf serve` on a paired Linux or macOS home now also opens the task door and runs each task it accepts as a `codeaf do` child in the attempt's own folder, with a hidden flag that keeps the child's run record there. The door follows the child's steps and per-call spend into the attempt's journal, stops it on request, and after a restart adopts a child that still lives; a child that died without an answer ends interrupted and is never started again. The task's effort, step and no-progress controls travel with it. Co-Authored-By: Claude Opus 5.5 --- cmd/codeaf/chatv3_at.go | 1 + cmd/codeaf/do.go | 40 +- cmd/codeaf/do_task_launch_test.go | 122 ++++++ cmd/codeaf/do_task_store.go | 84 ++++ cmd/codeaf/do_task_store_test.go | 251 +++++++++++ cmd/codeaf/serve_tasks.go | 87 ++++ cmd/codeaf/serve_tasks_test.go | 104 +++++ internal/run/bashworker.go | 21 +- internal/run/trajectory.go | 35 ++ internal/taskdoor/attempts.go | 31 +- internal/taskdoor/executor.go | 634 ++++++++++++++++++++++++++++ internal/taskdoor/executor_test.go | 508 ++++++++++++++++++++++ internal/taskdoor/process_darwin.go | 45 ++ internal/taskdoor/process_linux.go | 69 +++ internal/taskdoor/process_other.go | 20 + internal/taskdoor/process_unix.go | 17 + internal/taskdoor/serve.go | 11 +- 17 files changed, 2068 insertions(+), 12 deletions(-) create mode 100644 cmd/codeaf/do_task_launch_test.go create mode 100644 cmd/codeaf/do_task_store.go create mode 100644 cmd/codeaf/do_task_store_test.go create mode 100644 cmd/codeaf/serve_tasks.go create mode 100644 cmd/codeaf/serve_tasks_test.go create mode 100644 internal/taskdoor/executor.go create mode 100644 internal/taskdoor/executor_test.go create mode 100644 internal/taskdoor/process_darwin.go create mode 100644 internal/taskdoor/process_linux.go create mode 100644 internal/taskdoor/process_other.go create mode 100644 internal/taskdoor/process_unix.go diff --git a/cmd/codeaf/chatv3_at.go b/cmd/codeaf/chatv3_at.go index 0f05496e07..1d6340d7b9 100644 --- a/cmd/codeaf/chatv3_at.go +++ b/cmd/codeaf/chatv3_at.go @@ -300,6 +300,7 @@ func runServe(args []string) error { ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() defer startServeKeeping(ctx, service)() + defer serveTasks(ctx, service)() host := &pair.Host{ Service: service, Device: device, diff --git a/cmd/codeaf/do.go b/cmd/codeaf/do.go index ceee130452..032d5e1e1c 100644 --- a/cmd/codeaf/do.go +++ b/cmd/codeaf/do.go @@ -28,6 +28,7 @@ import ( "github.com/Agent-Field/codeaf/internal/crewroute" "github.com/Agent-Field/codeaf/internal/ctxbudget" "github.com/Agent-Field/codeaf/internal/devname" + "github.com/Agent-Field/codeaf/internal/effort" "github.com/Agent-Field/codeaf/internal/env" "github.com/Agent-Field/codeaf/internal/exec" "github.com/Agent-Field/codeaf/internal/head" @@ -39,6 +40,7 @@ import ( runengine "github.com/Agent-Field/codeaf/internal/run" "github.com/Agent-Field/codeaf/internal/session" "github.com/Agent-Field/codeaf/internal/store" + "github.com/Agent-Field/codeaf/internal/taskdoor" "github.com/Agent-Field/codeaf/internal/trace" ) @@ -327,6 +329,9 @@ func runDo(args []string) error { "(older engine only; the run engine refuses it)") keep := flags.Bool("keep", false, "keep the run's store instead of deleting it on the way out, "+ "and say where it is") + // This private flag is the task door's process boundary, hidden from help + // and the manual because only serve supplies an owned attempt directory. + runStore := flags.String(taskdoor.TaskStoreFlag, "", hiddenShorthand+taskdoor.TaskStoreFlag) workspace := flags.String("dir", "", "the directory to work in, edited in place (default: the current directory)") shorthandFlag(flags, "w", "dir") wall := wallFlag{wall: defaultDoWall} @@ -364,6 +369,9 @@ func runDo(args []string) error { if err := parseCommandFlags(flags, reorder(flags, args)); err != nil { return err } + if err := waitTaskLaunch(*runStore); err != nil { + return refuseDoBeforeRun(*asJSON, "", err) + } noteRenamedFlags(flags) slots, err := parseSlots(*slotsRaw) if err != nil { @@ -400,7 +408,7 @@ func runDo(args []string) error { } return doErrand(doRequest{ effort: effort, pins: pins.pins, - task: next.brief, assignment: next.assignment, cellRoot: next.cellRoot, run: run, database: *database, keep: *keep, workspace: *workspace, + task: next.brief, assignment: next.assignment, cellRoot: next.cellRoot, run: run, database: *database, keep: *keep, workspace: *workspace, runStore: *runStore, timeout: wall.wall, asJSON: *asJSON, yesSpend: *yesSpend, model: *model, planModel: *planModel, checkModel: *checkModel, contextFill: *contextFill, completionReserve: *completionReserve, slots: slots, @@ -439,6 +447,7 @@ type doRequest struct { run string database string keep bool + runStore string workspace string timeout time.Duration asJSON bool @@ -769,6 +778,9 @@ func errandRun(request doRequest, seats config.Seats, started time.Time) (outcom if session.BashBeltAsked() { return runErrand(request, seats) } + if request.runStore != "" { + return headlessOutcome{}, errors.New("this computer's task belt cannot take tasks from another computer") + } path, home, ephemeral, err := headlessStore(request.database) if err != nil { return headlessOutcome{}, err @@ -3638,6 +3650,15 @@ func parseSlots(raw string) (*int, error) { // door was handed is the whole assignment, which is the same verbatim contract // the resident road keeps. func runErrand(request doRequest, seats config.Seats) (outcome headlessOutcome, runErr error) { + if request.runStore != "" { + // The task door reads per-call ledger rows after this process exits; + // queued final charges must reach disk before its receipt is printed. + defer session.CloseUsage() + } + remote, rung, err := doTaskControls(request.runStore) + if err != nil { + return headlessOutcome{}, err + } // A FLAG THIS ROAD CANNOT HONOUR IS REFUSED IN WORDS, NEVER DROPPED. `--db` // names a store the older engine works in; a run keeps its plan in its own // private folder instead, and a run that quietly worked somewhere @@ -3658,6 +3679,9 @@ func runErrand(request doRequest, seats config.Seats) (outcome headlessOutcome, if err != nil { return headlessOutcome{}, err } + if remote.Limits.USD > 0 && (bound.usd <= 0 || remote.Limits.USD < bound.usd) { + bound.usd, bound.stop, bound.words = remote.Limits.USD, stopBudget, "the task reached the spending limit set on the other computer" + } if bound.refused { // A CEILING OF NOTHING IS A RUN THAT MAY SPEND NOTHING. Refused here, // before anything is opened or built, because a limit of zero is not a @@ -3697,7 +3721,7 @@ func runErrand(request doRequest, seats config.Seats) (outcome headlessOutcome, // run's business ([session.RunTreeSnapshot]). before := session.SnapshotRunTree(workspace) title := topicTitle(request.headline()) - _, recordDir, _, err := headlessStore("") + recordDir, err := doRunStore(request.runStore) if err != nil { return headlessOutcome{}, err } @@ -3705,6 +3729,10 @@ func runErrand(request doRequest, seats config.Seats) (outcome headlessOutcome, // The record is private and unique per invocation. The existing headless // home owns both its location and the rule for when it survives a run. defer func() { + if request.runStore != "" { + outcome.recordKept = recordDir + return + } if !admitted { _ = os.RemoveAll(recordDir) return @@ -3747,7 +3775,11 @@ func runErrand(request doRequest, seats config.Seats) (outcome headlessOutcome, // THE REVIEW ROUND IS ON for every `do` run: a leaf that lands done is // checked against its acceptance, and a check that does not hold becomes a // fix task under the leaf's parent the run waits on. - limits := runengine.Limits{ReviewRound: true, CostUSD: bound.usd} + limits := runengine.Limits{ReviewRound: true, CostUSD: bound.usd, StepsPerTask: remote.MaxSteps} + workerOptions := inCell.workerOptions() + if rung != effort.None || remote.NoProgress > 0 { + workerOptions = append(workerOptions, runengine.WithTaskControls(rung, remote.NoProgress)) + } // AN INTERRUPT MUST LAND THE RUN, NOT VANISH IT, the same way it must on the // resident road: routed through the context, the supervisor stops launching, // drains what is in flight, and what it reached is composed and printed. @@ -3781,7 +3813,7 @@ func runErrand(request doRequest, seats config.Seats) (outcome headlessOutcome, Work: seats.Work.Model, Plan: seats.Plan.Model, Check: seats.Check.Model, - }, memorySection, spendPreauthorized(request.yesSpend, env.Value), completerFor, inCell.workerOptions(), settings.Sources), + }, memorySection, spendPreauthorized(request.yesSpend, env.Value), completerFor, workerOptions, settings.Sources), }) errand := runErrandOutcome(summary, workspace, bound) // THE RUN'S OWN CLOCK, read off the context because the summary's word is diff --git a/cmd/codeaf/do_task_launch_test.go b/cmd/codeaf/do_task_launch_test.go new file mode 100644 index 0000000000..8a18c45915 --- /dev/null +++ b/cmd/codeaf/do_task_launch_test.go @@ -0,0 +1,122 @@ +package main + +import ( + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/taskdoor" +) + +func TestTaskLaunchGateHelper(t *testing.T) { + if os.Getenv("CODEAF_TASK_LAUNCH_HELPER") != "1" { + return + } + t.Setenv("CODEAF_TELEMETRY", "off") + t.Setenv("DO_NOT_TRACK", "1") + var args []string + for n, arg := range os.Args { + if arg == "--" { + args = os.Args[n+1:] + break + } + } + _ = os.WriteFile(filepath.Join(os.Getenv("CODEAF_HOME"), "entered"), nil, 0600) + os.Exit(exitCodeOf(runDo(args))) +} + +// Contract 12: the actual hidden command flag prevents work before its pid +// is durable. A launch abandoned during that window exits without tools. +func TestDoWaitsForDurableTaskAdmissionBeforeStarting(t *testing.T) { + for _, mode := range []string{"ended", "registered"} { + t.Run(mode, func(t *testing.T) { + home, err := os.MkdirTemp("", "rn-l1-") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(home) + t.Setenv("HOME", home) + t.Setenv("CODEAF_HOME", home) + t.Setenv("CODEAF_TELEMETRY", "off") + t.Setenv("DO_NOT_TRACK", "1") + attempt := filepath.Join(home, "attempt") + record := filepath.Join(attempt, "run") + if err := os.MkdirAll(record, 0700); err != nil { + t.Fatal(err) + } + statePath := filepath.Join(attempt, "state.json") + writeState := func(state taskdoor.State) { + t.Helper() + raw, err := json.Marshal(state) + if err != nil { + t.Fatal(err) + } + staged := statePath + ".new" + if err := os.WriteFile(staged, raw, 0600); err != nil { + t.Fatal(err) + } + if err := os.Rename(staged, statePath); err != nil { + t.Fatal(err) + } + } + writeState(taskdoor.State{Phase: "running", LaunchStartedAt: time.Now()}) + out, err := os.Create(filepath.Join(attempt, "do.json")) + if err != nil { + t.Fatal(err) + } + defer out.Close() + log, err := os.Create(filepath.Join(attempt, "do.log")) + if err != nil { + t.Fatal(err) + } + defer log.Close() + // Omitting the brief makes even a broken gate fail before constructing a + // provider, so this regression can never contact a real model endpoint. + cmd := exec.Command(os.Args[0], "-test.run=^TestTaskLaunchGateHelper$", "--", "--task-store", record, "--json") + cmd.Env = append(os.Environ(), "CODEAF_TASK_LAUNCH_HELPER=1") + cmd.Stdout, cmd.Stderr = out, log + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + defer cmd.Process.Kill() + deadline := time.Now().Add(5 * time.Second) + for { + if _, err := os.Stat(filepath.Join(home, "entered")); err == nil { + break + } + if time.Now().After(deadline) { + t.Fatal("child did not reach launch gate") + } + time.Sleep(5 * time.Millisecond) + } + time.Sleep(20 * time.Millisecond) + if info, err := out.Stat(); err != nil || info.Size() != 0 { + t.Fatalf("child passed an undurable admission: %v, %v", info, err) + } + next := taskdoor.State{Phase: "interrupted"} + want := "this task attempt has already ended" + if mode == "registered" { + next = taskdoor.State{Phase: "running", PID: cmd.Process.Pid, ProcessStart: "fixture-start"} + want = "no goal given" + } + writeState(next) + if err := cmd.Wait(); err == nil { + t.Fatal("abandoned launch succeeded") + } + raw, err := os.ReadFile(out.Name()) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(raw), want) { + t.Fatalf("wrong launch refusal: %s", raw) + } + if entries, err := os.ReadDir(record); err != nil || len(entries) != 0 { + t.Fatalf("abandoned launch started a run: %v, %v", entries, err) + } + }) + } +} diff --git a/cmd/codeaf/do_task_store.go b/cmd/codeaf/do_task_store.go new file mode 100644 index 0000000000..f101dd547d --- /dev/null +++ b/cmd/codeaf/do_task_store.go @@ -0,0 +1,84 @@ +package main + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "time" + + "github.com/Agent-Field/codeaf/internal/effort" + "github.com/Agent-Field/codeaf/internal/taskdoor" +) + +// doRunStore keeps the task door's record outside the copy and refuses reuse: +// a kept attempt can be followed after a crash but must never run twice. +func doRunStore(owned string) (string, error) { + if owned == "" { + _, dir, _, err := headlessStore("") + return dir, err + } + if !filepath.IsAbs(owned) { + return "", errors.New("the task's private record needs an absolute folder") + } + entries, err := os.ReadDir(owned) + if err == nil && len(entries) > 0 { + return "", errors.New("the task's private record already contains a run") + } + if err != nil && !errors.Is(err, os.ErrNotExist) { + return "", err + } + if err := os.MkdirAll(owned, 0700); err != nil { + return "", err + } + return owned, nil +} + +// waitTaskLaunch lets a surviving child be found after a serve crash before +// any model call or tool can run. The door releases it by saving its pid. +func waitTaskLaunch(record string) error { + if record == "" { + return nil + } + for { + raw, err := os.ReadFile(filepath.Join(filepath.Dir(record), "state.json")) + if err != nil { + return err + } + var state taskdoor.State + if err := json.Unmarshal(raw, &state); err != nil { + return err + } + if state.Phase != "running" || state.Abandoned { + return errors.New("this task attempt has already ended") + } + if state.PID == os.Getpid() && state.ProcessStart != "" { + return nil + } + time.Sleep(10 * time.Millisecond) + } +} + +func doTaskControls(record string) (taskdoor.Start, effort.Rung, error) { + var request taskdoor.Start + if record == "" { + return request, effort.None, nil + } + raw, err := os.ReadFile(filepath.Join(filepath.Dir(record), "request.json")) + // Direct in-process command tests can name an owned store without a door. + // A real serve admission always wrote request.json before the child ran. + if errors.Is(err, os.ErrNotExist) { + return request, effort.None, nil + } + if err != nil { + return request, effort.None, err + } + if err := json.Unmarshal(raw, &request); err != nil { + return request, effort.None, err + } + rung, ok := effort.Parse(request.Effort) + if !ok { + return request, effort.None, errors.New("the task's reasoning effort is not readable") + } + return request, rung, nil +} diff --git a/cmd/codeaf/do_task_store_test.go b/cmd/codeaf/do_task_store_test.go new file mode 100644 index 0000000000..1f9d3f31e7 --- /dev/null +++ b/cmd/codeaf/do_task_store_test.go @@ -0,0 +1,251 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "math" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/Agent-Field/agentfield/sdk/go/ai" + "github.com/Agent-Field/codeaf/internal/provider" + "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/taskdoor" +) + +type taskRunJournal struct { + spend []taskdoor.Spend + steps []taskdoor.Step + end taskdoor.Finished +} + +type taskBillingProvider struct { + session.Completer + callsFile string +} + +func (p taskBillingProvider) CompleteWithMessages(ctx context.Context, messages []ai.Message, options ...ai.Option) (*ai.Response, error) { + file, err := os.OpenFile(p.callsFile, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600) + if err != nil { + return nil, err + } + _, writeErr := file.WriteString("call\n") + closeErr := file.Close() + if err := errors.Join(writeErr, closeErr); err != nil { + return nil, err + } + return p.Completer.CompleteWithMessages(ctx, messages, options...) +} + +func (j *taskRunJournal) Append(event any) error { + switch event := event.(type) { + case taskdoor.Spend: + j.spend = append(j.spend, event) + case taskdoor.Step: + j.steps = append(j.steps, event) + case taskdoor.Finished: + j.end = event + } + return nil +} + +// The subprocess exits immediately after the real do road returns, so the +// parent cannot accidentally flush its queued usage rows on the child's behalf. +func TestTaskDoBillHelper(t *testing.T) { + if os.Getenv("CODEAF_TASK_DO_BILL_HELPER") != "1" { + return + } + // This package's TestMain deliberately clears credentials/profile and + // selects the older belt. Reapply only this fixture's disposable inputs. + t.Setenv("HOME", os.Getenv("CODEAF_TEST_TASK_RUN_HOME")) + t.Setenv("CODEAF_HOME", os.Getenv("CODEAF_TEST_TASK_RUN_HOME")) + t.Setenv("CODEAF_PROFILE_DIR", os.Getenv("CODEAF_TEST_TASK_RUN_PROFILE")) + t.Setenv("CODEAF_TASK_BELT", "bash") + t.Setenv("OPENROUTER_API_KEY", "test-key") + t.Setenv("CODEAF_TELEMETRY", "off") + t.Setenv("DO_NOT_TRACK", "1") + value := func(flag string) string { + for n, arg := range os.Args { + if arg == flag && n+1 < len(os.Args) { + return os.Args[n+1] + } + } + return "" + } + err := doErrand(doRequest{task: "write out.txt", workspace: value("--dir"), runStore: value("--task-store"), asJSON: true, timeout: 45 * time.Second, + stdout: os.Stdout, stderr: os.Stderr, newBeltCompleter: func(string) session.Completer { + return taskBillingProvider{finishingSeat(0.01), filepath.Join(filepath.Dir(value("--task-store")), "provider-calls")} + }}) + os.Exit(exitCodeOf(err)) +} + +// Contracts 5 and 12: the actual do run keeps the requested private record, +// and the executor's per-call ledger source sums to that run's own receipt. +func TestTheTaskDoorKeepsTheRealDoRecordAndItsWholeBill(t *testing.T) { + beltRunEnv(t) + home, err := os.MkdirTemp("", "rn-l1-") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(home) + t.Setenv("HOME", home) + t.Setenv("CODEAF_HOME", home) + t.Setenv("CODEAF_TELEMETRY", "off") + t.Setenv("DO_NOT_TRACK", "1") + t.Setenv("CODEAF_PLANDB_BIN", beltPlandbDoor(t)) + attempt := filepath.Join(home, "att_0000000000000081") + record := filepath.Join(attempt, "run") + work := filepath.Join(attempt, "work") + if err := os.MkdirAll(attempt, 0700); err != nil { + t.Fatal(err) + } + // A received task has its own Git history. The do receipt reads changed + // files from that history, so a plain directory would not exercise it. + if err := os.Rename(beltRepoWorkspace(t), work); err != nil { + t.Fatal(err) + } + var stdout, stderr strings.Builder + jsonFile, err := os.Create(filepath.Join(attempt, "do.json")) + if err != nil { + t.Fatal(err) + } + defer jsonFile.Close() + logFile, err := os.Create(filepath.Join(attempt, "do.log")) + if err != nil { + t.Fatal(err) + } + defer logFile.Close() + cmd := exec.Command(os.Args[0], "-test.run=^TestTaskDoBillHelper$", "--", "--dir", work, "--task-store", record) + cmd.Env = append(os.Environ(), "CODEAF_TASK_DO_BILL_HELPER=1", "CODEAF_HOME="+filepath.Join(home, "bootstrap"), "CODEAF_TEST_TASK_RUN_HOME="+home, "CODEAF_TEST_TASK_RUN_PROFILE="+os.Getenv("CODEAF_PROFILE_DIR")) + cmd.Stdout, cmd.Stderr = jsonFile, logFile + err = cmd.Run() + jsonRaw, _ := os.ReadFile(jsonFile.Name()) + logRaw, _ := os.ReadFile(logFile.Name()) + stdout.Write(jsonRaw) + stderr.Write(logRaw) + if err != nil { + t.Fatalf("run: %v\nstdout: %s\nstderr: %s", err, stdout.String(), stderr.String()) + } + if _, err := os.Stat(filepath.Join(record, "plandb.db")); err != nil { + t.Fatal("private run was not kept", err) + } + if stores, _ := filepath.Glob(filepath.Join(home, "runs", "codeaf-do-*")); len(stores) != 0 { + t.Fatalf("record escaped attempt: %v", stores) + } + if err := os.WriteFile(filepath.Join(attempt, "events.jsonl"), nil, 0600); err != nil { + t.Fatal(err) + } + journal := &taskRunJournal{} + a := &taskdoor.Attempt{ID: filepath.Base(attempt), Dir: attempt} + if err := taskdoor.NewExecutor(taskdoor.ExecutorConfig{Ledger: session.UsageLedgerPath()}).Adopt(t.Context(), a, journal); err != nil { + t.Fatal(err) + } + var bill struct { + Spend float64 `json:"spend_usd"` + } + if err := json.Unmarshal([]byte(stdout.String()), &bill); err != nil { + t.Fatal(err) + } + var sum float64 + for _, row := range journal.spend { + sum += row.Line.USD + } + providerCalls, err := os.ReadFile(filepath.Join(attempt, "provider-calls")) + if err != nil { + t.Fatal(err) + } + if len(journal.spend) != len(strings.Fields(string(providerCalls))) { + t.Fatalf("journal charges=%d provider calls=%q", len(journal.spend), providerCalls) + } + if journal.end.Outcome != "done" { + t.Fatalf("completed child did not finish normally: %+v", journal.end) + } + if len(journal.end.Changed) != 1 || journal.end.Changed[0] != "out.txt" { + t.Fatalf("changed files are not portable: %v", journal.end.Changed) + } + var order uint64 + for _, line := range journal.steps { + if line.Step.RecordedOrder <= order { + t.Fatalf("steps lost write order: %+v", journal.steps) + } + order = line.Step.RecordedOrder + } + if len(journal.spend) < 3 || bill.Spend <= 0 || math.Abs(sum-bill.Spend) > 1e-9 { + t.Fatalf("journal=%+v sum=%g actual bill=%g", journal.spend, sum, bill.Spend) + } +} + +// The task-store flag is internal serve plumbing, so the ordinary do help +// must not promise a user-facing option for it. +func TestTheTaskStoreFlagIsAbsentFromDoHelp(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("CODEAF_HOME", t.TempDir()) + t.Setenv("CODEAF_TELEMETRY", "off") + t.Setenv("DO_NOT_TRACK", "1") + out, _ := captureUsage(t) + if code := exitCodeOf(runDo([]string{"--help"})); code != 0 { + t.Fatalf("help exit=%d", code) + } + text := out.String() + if strings.Contains(text, "--"+taskdoor.TaskStoreFlag) { + t.Fatalf("private flag appeared in help: %s", text) + } +} + +// The task's settled limits and effort travel to the real run, while this +// computer's own spending bound continues to constrain it. +func TestTheTaskDoorHonorsTheSettledTaskControls(t *testing.T) { + for _, kind := range []string{"steps", "spend", "no progress"} { + t.Run(kind, func(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + beltRunEnv(t) + t.Setenv("CODEAF_TELEMETRY", "off") + t.Setenv("DO_NOT_TRACK", "1") + attempt := t.TempDir() + record := filepath.Join(attempt, "run") + work := beltRepoWorkspace(t) + settled := taskdoor.Start{Effort: "high"} + switch kind { + case "steps": + settled.MaxSteps = 2 + case "spend": + settled.Limits.USD = 0.02 + case "no progress": + settled.NoProgress = 2 + } + raw, err := json.Marshal(settled) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(attempt, "request.json"), raw, 0600); err != nil { + t.Fatal(err) + } + calls := 0 + seat := &beltSeat{ever: func(ctx context.Context, _ []ai.Message) (*ai.Response, error) { + calls++ + if rung := provider.ReasoningEffortFrom(ctx); rung != provider.EffortHigh { + t.Errorf("effort=%s", rung) + } + reply := beltToolReply("printf 'same answer'") + cost := 0.01 + reply.Usage.Cost = &cost + return reply, nil + }} + out, err := runErrand(doRequest{task: "work", workspace: work, runStore: record, timeout: 30 * time.Second, stderr: &strings.Builder{}, newBeltCompleter: func(string) session.Completer { return seat }}, stubErrandSeats()) + if err != nil { + t.Fatal(err) + } + if calls != 2 { + t.Fatalf("settled %s allowed %d provider calls, outcome=%+v", kind, calls, out) + } + if kind == "spend" && out.status() != exitLimit { + t.Fatalf("spending bound did not stop run: %+v", out) + } + }) + } +} diff --git a/cmd/codeaf/serve_tasks.go b/cmd/codeaf/serve_tasks.go new file mode 100644 index 0000000000..21fc208fcc --- /dev/null +++ b/cmd/codeaf/serve_tasks.go @@ -0,0 +1,87 @@ +package main + +import ( + "context" + "fmt" + "net/http" + "os" + "path/filepath" + "runtime" + "time" + + "github.com/Agent-Field/codeaf/internal/blobstore" + "github.com/Agent-Field/codeaf/internal/cellstore" + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/pair" + "github.com/Agent-Field/codeaf/internal/reqsign" + "github.com/Agent-Field/codeaf/internal/syncsetup" + "github.com/Agent-Field/codeaf/internal/taskdoor" +) + +// serveTasks shares the machine door's lifetime, relay and output. Its own +// cancellation joins followers while deliberately leaving their children alive. +func serveTasks(ctx context.Context, service string) func() { + profile := home.Dir() + if syncsetup.Quiet(profile) { + return func() {} + } + if runtime.GOOS == "windows" { + fmt.Fprintln(os.Stdout, taskdoor.UnsupportedComputerLine) + return func() {} + } + config, ok, err := serveTasksConfig(profile, service) + if err != nil { + fmt.Fprintln(os.Stderr, "could not take tasks from your paired computers:", err) + return func() {} + } + if !ok { + return func() {} + } + config.Say = func(line string) { fmt.Fprintln(os.Stdout, line) } + ctx, cancel := context.WithCancel(ctx) + done := make(chan struct{}) + go func() { + defer close(done) + if err := taskdoor.Serve(ctx, config); err != nil && ctx.Err() == nil { + fmt.Fprintln(os.Stderr, "could not take tasks from your paired computers:", err) + } + }() + return func() { cancel(); <-done } +} + +func serveTasksConfig(profile, service string) (taskdoor.Config, bool, error) { + absolute, err := filepath.Abs(profile) + if err != nil { + return taskdoor.Config{}, false, err + } + profile = absolute + if runtime.GOOS == "windows" || syncsetup.Quiet(profile) { + return taskdoor.Config{}, false, nil + } + client, id, err := homeDirectory(profile, pair.Mailbox{URL: service}) + if err != nil { + return taskdoor.Config{}, false, err + } + device, err := identity.LoadDevice(profile) + if err != nil { + return taskdoor.Config{}, false, err + } + engine := cellstore.EngineFor("") + engine.DataRoot = filepath.Join(profile, "v3", "stores") + engine.Guard.Home = profile + store := blobstore.NewHTTP(service, reqsign.SignFor(homeSigner{id, device}, time.Now), &http.Client{}) + copies := taskdoor.CopyDeps{Root: filepath.Join(profile, "v3", "taskcopies"), Service: service, Identity: id, Device: device, Engine: engine, Store: store} + return taskdoor.Config{Service: service, Identity: id, Device: device, Home: profile, Root: filepath.Join(profile, "v3", "taskdoor"), + Roster: func(ctx context.Context) (map[string]bool, error) { + listing, err := client.List(ctx) + if err != nil { + return nil, err + } + active := make(map[string]bool, len(listing.Devices)) + for id, device := range listing.Devices { + active[id] = !device.Revoked + } + return active, nil + }, Executor: taskdoor.NewExecutor(taskdoor.ExecutorConfig{Copies: copies, Ledger: filepath.Join(profile, "v3", "usage.jsonl")})}, true, nil +} diff --git a/cmd/codeaf/serve_tasks_test.go b/cmd/codeaf/serve_tasks_test.go new file mode 100644 index 0000000000..d5cda23e4a --- /dev/null +++ b/cmd/codeaf/serve_tasks_test.go @@ -0,0 +1,104 @@ +package main + +import ( + "context" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/reqsign" +) + +// DESIGN section 2: only a paired, sync-enabled serve opens a task door; its +// roster is the identity directory, independent of machine-access grants. +func TestServeTasksUsesThePairedIdentityRoster(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("CODEAF_HOME", t.TempDir()) + t.Setenv("CODEAF_TELEMETRY", "off") + t.Setenv("DO_NOT_TRACK", "1") + profile := t.TempDir() + id, err := identity.Ensure(profile) + if err != nil { + t.Fatal(err) + } + dev, err := identity.LoadDevice(profile) + if err != nil { + t.Fatal(err) + } + state := directory.NewMemory(time.Now) + active, err := identity.NewDevice(id) + if err != nil { + t.Fatal(err) + } + removed, err := identity.NewDevice(id) + if err != nil { + t.Fatal(err) + } + client := state.For(dev.ID()) + for _, d := range []identity.Dev{dev, active, removed} { + if err := client.PutDevice(context.Background(), d.ID(), directory.Device{V: 1}); err != nil { + t.Fatal(err) + } + } + if err := client.Revoke(context.Background(), removed.ID()); err != nil { + t.Fatal(err) + } + // This fixture needs the actual signed directory wire, including its + // per-device revocation field, but no provider or remote service. + server := httptest.NewServer(directory.Handler(reqsign.AuthenticateAt(time.Now), func(string) (directory.Directory, error) { return state, nil })) + t.Cleanup(server.Close) + config, ok, err := serveTasksConfig(profile, server.URL) + if runtime.GOOS == "windows" { + if ok { + t.Fatal("Windows offered a task door") + } + return + } + if err != nil || !ok { + t.Fatalf("paired: %t, %v", ok, err) + } + if config.Root != filepath.Join(profile, "v3", "taskdoor") || config.Service != server.URL { + t.Fatalf("wrong attempt home: %+v", config) + } + roster, err := config.Roster(context.Background()) + if err != nil { + t.Fatal(err) + } + if !roster[active.ID()] || roster[removed.ID()] { + t.Fatalf("roster ignored revocation: %v", roster) + } + if _, err := os.Stat(filepath.Join(profile, "v3", "remote", "devices.json")); !os.IsNotExist(err) { + t.Fatal("task door consulted machine-access grants") + } +} + +// DESIGN section 2: quiet and unpaired homes do not register or mention tasks. +func TestServeTasksLeavesUnpairedAndQuietHomesAlone(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("CODEAF_HOME", t.TempDir()) + for _, kind := range []string{"unpaired", "solo", "off"} { + t.Run(kind, func(t *testing.T) { + profile := t.TempDir() + if kind == "solo" { + if _, err := identity.EnsureSolo(profile); err != nil { + t.Fatal(err) + } + } + if kind == "off" { + if _, err := identity.Ensure(profile); err != nil { + t.Fatal(err) + } + t.Setenv("CODEAF_SYNC_URL", "off") + } + _, ok, err := serveTasksConfig(profile, "http://127.0.0.1:8999") + if err != nil || ok { + t.Fatalf("%s opened a task door: %t, %v", kind, ok, err) + } + }) + } +} diff --git a/internal/run/bashworker.go b/internal/run/bashworker.go index 537b773b94..816b870e8d 100644 --- a/internal/run/bashworker.go +++ b/internal/run/bashworker.go @@ -18,6 +18,7 @@ import ( "strings" "time" + "github.com/Agent-Field/codeaf/internal/effort" procexec "github.com/Agent-Field/codeaf/internal/executor" "github.com/Agent-Field/codeaf/internal/plandb" "github.com/Agent-Field/codeaf/internal/provider" @@ -86,11 +87,20 @@ type BashWorker struct { seat procexec.Seat workspaceURI string workspaceSet *session.WorkspaceSet + // The task door carries the settled task's controls into this child run. + taskEffort effort.Rung + noProgress int } // WorkerOption changes how a worker is built; the zero set is the host worker. type WorkerOption func(*BashWorker) +// WithTaskControls carries an explicitly settled task's controls without +// changing this computer's saved defaults or spending permission. +func WithTaskControls(rung effort.Rung, noProgress int) WorkerOption { + return func(w *BashWorker) { w.taskEffort = rung; w.noProgress = noProgress } +} + // WithWorkspaceURI carries the admitted identity without deriving it from a path. func WithWorkspaceURI(uri string) WorkerOption { return func(w *BashWorker) { w.workspaceURI = uri } @@ -155,6 +165,7 @@ func (w *BashWorker) Run(ctx context.Context, task plandb.Task) (rep Report, run } } agent, err := session.NewBeltWorker(session.Config{ + Effort: w.taskEffort, Workspace: w.workspace, WorkspaceURI: w.workspaceURI, WorkspaceSet: w.workspaceSet.Clone(), @@ -231,6 +242,10 @@ func (w *BashWorker) Run(ctx context.Context, task plandb.Task) (rep Report, run // the store and still unread until a turn takes them, so a refused splice // leaves it to the next boundary rather than to this carry. var owed []string + stalledAfter := sameStepNote + sameStepEnd + if w.noProgress > 0 { + stalledAfter = w.noProgress + } // readNotes is the ids of this task's notes THIS WORKER has already been // handed. THE MARK IS THE WORKER'S ALONE and lives only for the life of the // loop: a person opening the task's page reads the same notes without @@ -370,7 +385,7 @@ func (w *BashWorker) Run(ctx context.Context, task plandb.Task) (rep Report, run owed = append(owed, sameStepSpoken()) } } - if same >= sameStepNote+sameStepEnd { + if same >= stalledAfter { // A TASK THE STORE SAYS IS BLOCKED ON ANOTHER TASK goes to // the parking the belt already has rather than an ending: // `plandb wait` refuses a wait with nothing open, so a park @@ -380,7 +395,7 @@ func (w *BashWorker) Run(ctx context.Context, task plandb.Task) (rep Report, run // work. The worker was told before the ending came, so // the park is not the first the worker hears of the law. if _, waitErr := w.store.Wait(task.ID, task.ID); waitErr == nil { - ending = storeEnding{kind: endingWait, reason: fmt.Sprintf("waiting: the same command came back with the same answer %d times in a row", sameStepNote+sameStepEnd)} + ending = storeEnding{kind: endingWait, reason: fmt.Sprintf("waiting: the same command came back with the same answer %d times in a row", stalledAfter)} stop() } else { stalled = true @@ -501,7 +516,7 @@ func (w *BashWorker) Run(ctx context.Context, task plandb.Task) (rep Report, run // figure counts the whole run of identical steps, the three that // brought the note and the three that followed it, because that is // what a person opening the record counts. - reason := fmt.Sprintf("the same command came back with the same answer %d times in a row: the work was not moving", sameStepNote+sameStepEnd) + reason := fmt.Sprintf("the same command came back with the same answer %d times in a row: the work was not moving", stalledAfter) if err := appendTrajectory(storeDir, task.ID, Step{Kind: trajectoryEndKind, ExitsRecorded: true, Steps: steps, Reason: reason}); err != nil { return Report{Steps: steps}, err } diff --git a/internal/run/trajectory.go b/internal/run/trajectory.go index ed5723ad89..698b61acd9 100644 --- a/internal/run/trajectory.go +++ b/internal/run/trajectory.go @@ -16,6 +16,7 @@ import ( "os" "path/filepath" "strings" + "sync" "time" "unicode/utf8" @@ -55,6 +56,10 @@ const observationHeadBytes = 2048 // line: Kind says which one a line is. type Step struct { Kind string `json:"kind"` + // RecordedAt retains append timing. RecordedOrder lets a follower wait + // for an earlier append observed later in another task's file. + RecordedAt time.Time `json:"recorded_at,omitzero"` + RecordedOrder uint64 `json:"recorded_order,omitempty"` // Step is the step's number, counted from one over the task across its wakes. Step int `json:"step"` // Tool identifies the native action; older records leave it empty. @@ -185,11 +190,38 @@ func Trajectory(storeDir, id string) ([]Step, error) { return steps, nil } +// One writer orders all trajectories of a run. The follower can wait for a +// missing order when a directory scan observes a later task's append first. +var trajectoryWriter = struct { + sync.Mutex + orders map[string]uint64 +}{orders: map[string]uint64{}} + // appendTrajectory appends one line to the task's trajectory file, creating // the task's record folder when the first line arrives. Appends are one // write: the record grows in the order the worker took its steps, and a // reader walking the file sees the run the way it happened. func appendTrajectory(storeDir, id string, step Step) error { + trajectoryWriter.Lock() + defer trajectoryWriter.Unlock() + order, known := trajectoryWriter.orders[storeDir] + if !known { + paths, _ := filepath.Glob(filepath.Join(storeDir, "tasks", "*", trajectoryName)) + for _, path := range paths { + raw, err := os.ReadFile(path) + if err != nil { + return err + } + for _, encoded := range strings.Split(string(raw), "\n") { + var line Step + if json.Unmarshal([]byte(encoded), &line) == nil && line.RecordedOrder > order { + order = line.RecordedOrder + } + } + } + } + step.RecordedAt = time.Now() + step.RecordedOrder = order + 1 dir := plandb.TaskDir(storeDir, id) if err := os.MkdirAll(dir, 0o700); err != nil { return err @@ -205,6 +237,9 @@ func appendTrajectory(storeDir, id string, step Step) error { } defer f.Close() _, err = f.Write(append(line, '\n')) + if err == nil { + trajectoryWriter.orders[storeDir] = step.RecordedOrder + } return err } diff --git a/internal/taskdoor/attempts.go b/internal/taskdoor/attempts.go index 5cc49883f8..891005400e 100644 --- a/internal/taskdoor/attempts.go +++ b/internal/taskdoor/attempts.go @@ -33,10 +33,18 @@ type Executor interface { type Journal interface{ Append(event any) error } type State struct { - Phase string `json:"phase"` - PID int `json:"pid,omitempty"` - StartedAt time.Time `json:"started_at,omitzero"` - FinishedAt time.Time `json:"finished_at,omitzero"` + Phase string `json:"phase"` + PID int `json:"pid,omitempty"` + StartedAt time.Time `json:"started_at,omitzero"` + FinishedAt time.Time `json:"finished_at,omitzero"` + ChildStartedAt time.Time `json:"child_started_at,omitzero"` + ProcessStart string `json:"process_start,omitempty"` + CommandHash string `json:"command_hash,omitempty"` + ExitCode *int `json:"exit_code,omitempty"` + StopRequestedAt time.Time `json:"stop_requested_at,omitzero"` + LedgerOffset int64 `json:"ledger_offset,omitempty"` + LaunchStartedAt time.Time `json:"launch_started_at,omitzero"` + Abandoned bool `json:"abandoned,omitempty"` } // Attempt is an admitted immutable request and its private working folder. @@ -110,6 +118,7 @@ func openAttempts(root string, clock Clock) (*attemptStore, error) { if err := readJSON(filepath.Join(a.Dir, "state.json"), &a.state); err != nil { return nil, err } + a.removed = a.state.Abandoned events, err := readEvents(a.Dir) if err != nil { return nil, err @@ -214,6 +223,19 @@ func (s *attemptStore) forget(id string) error { } a.mu.Lock() defer a.mu.Unlock() + // A real child can still write its private run after SIGINT. Keep a + // durable tombstone until it is joined, including across a serving restart. + if a.state.Phase == "running" && (a.state.PID > 0 || !a.state.LaunchStartedAt.IsZero() || !a.state.StopRequestedAt.IsZero()) { + next := a.state + next.Abandoned = true + if err := atomicJSON(filepath.Join(a.Dir, "state.json"), next); err != nil { + return err + } + a.state = next + a.removed = true + a.notify() + return nil + } if err := os.RemoveAll(a.Dir); err != nil { return err } @@ -230,6 +252,7 @@ func (s *attemptStore) released(a *Attempt) { a.mu.Lock() defer a.mu.Unlock() if a.removed { + _ = os.RemoveAll(a.Dir) delete(s.attempts, a.ID) } } diff --git a/internal/taskdoor/executor.go b/internal/taskdoor/executor.go new file mode 100644 index 0000000000..3409f8121b --- /dev/null +++ b/internal/taskdoor/executor.go @@ -0,0 +1,634 @@ +package taskdoor + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "sync" + "time" + + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/run" +) + +const ( + DefaultTaskTimeout = 6 * time.Hour + ChildStopGrace = 30 * time.Second + // TaskStoreFlag is private plumbing between serve and do, hidden from help + // and deliberately absent from the manual because it is not a user option. + TaskStoreFlag = "task-store" + UnsupportedComputerLine = "this computer cannot take tasks from other computers yet" + childPoll = 100 * time.Millisecond +) + +type ExecutorConfig struct { + Copies CopyDeps + Binary string + // Command substitutes a child program at the operating-system boundary. + // It must retain args, including the attempt's work and run paths, so + // adoption can check that a recorded pid still belongs to this attempt. + Command func(args []string) *exec.Cmd + Ledger string + Clock Clock +} + +// ChildExecutor follows durable files rather than pipes. A serving process +// can therefore stop without killing the task or losing its eventual receipt. +type ChildExecutor struct { + config ExecutorConfig + clock Clock + mu sync.Mutex + active map[string]*Attempt + stops map[string]bool +} + +func NewExecutor(config ExecutorConfig) *ChildExecutor { + if config.Ledger == "" { + config.Ledger = home.Join("v3", "usage.jsonl") + } + return &ChildExecutor{config: config, clock: clockOrWall(config.Clock), active: map[string]*Attempt{}, stops: map[string]bool{}} +} + +func (e *ChildExecutor) attach(a *Attempt) error { + e.mu.Lock() + defer e.mu.Unlock() + e.active[a.ID] = a + if e.stops[a.ID] && a.State().StopRequestedAt.IsZero() { + return a.UpdateState(func(s *State) { s.StopRequestedAt = e.clock.Now() }) + } + return nil +} +func (e *ChildExecutor) detach(a *Attempt) { e.mu.Lock(); delete(e.active, a.ID); e.mu.Unlock() } + +func (e *ChildExecutor) Run(ctx context.Context, a *Attempt, j Journal) error { + if err := e.attach(a); err != nil { + return err + } + defer e.detach(a) + if err := a.UpdateState(func(s *State) { s.LaunchStartedAt = e.clock.Now() }); err != nil { + return err + } + work, record := filepath.Join(a.Dir, "work"), filepath.Join(a.Dir, "run") + if err := ReceiveCopy(ctx, e.config.Copies, a.Request.Input, work); err != nil { + return err + } + if err := ctx.Err(); err != nil { + return err + } + if !a.State().StopRequestedAt.IsZero() { + return e.finish(ctx, a, j, nil, false) + } + if err := os.MkdirAll(record, 0700); err != nil { + return err + } + wall := DefaultTaskTimeout + if a.Request.Limits.Hours > 0 { + wall = time.Duration(a.Request.Limits.Hours * float64(time.Hour)) + } + args := []string{"do", "--dir", work, "--json", "--" + TaskStoreFlag, record, "--timeout", wall.String()} + if a.Request.Model != "" { + args = append(args, "--model", a.Request.Model) + } + // A brief can itself be a flag-shaped word; it remains positional text. + args = append(args, "--", a.Request.Instruction) + var cmd *exec.Cmd + if e.config.Command != nil { + cmd = e.config.Command(args) + } else { + binary := e.config.Binary + if binary == "" { + var err error + binary, err = os.Executable() + if err != nil { + return err + } + } + cmd = exec.Command(binary, args...) + } + stdout, err := os.OpenFile(filepath.Join(a.Dir, "do.json"), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600) + if err != nil { + return err + } + defer stdout.Close() + stderr, err := os.OpenFile(filepath.Join(a.Dir, "do.log"), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600) + if err != nil { + return err + } + defer stderr.Close() + cmd.Stdout, cmd.Stderr = stdout, stderr + separateProcessGroup(cmd) + // Record the ledger boundary before starting, so other runs' historical + // charges never become work to scan on every beat of this attempt. + var ledgerOffset int64 + if info, err := os.Stat(e.config.Ledger); err == nil { + ledgerOffset = info.Size() + } + if err := a.UpdateState(func(s *State) { s.LedgerOffset = ledgerOffset; s.LaunchStartedAt = e.clock.Now() }); err != nil { + return err + } + e.mu.Lock() + err = cmd.Start() + if err != nil { + e.mu.Unlock() + return err + } + pid := cmd.Process.Pid + birth, hash, identityErr := childIdentity(pid, work, record) + // A launcher can still be replacing its argv immediately after Start. + // Give that handoff a bounded moment before recording the pinned identity. + for tries := 0; identityErr != nil && tries < 20; tries++ { + time.Sleep(10 * time.Millisecond) + birth, hash, identityErr = childIdentity(pid, work, record) + } + if identityErr != nil { + // A refusal can print its complete receipt and exit before inspection. + // Preserve that reason instead of replacing it with a pid-read error. + var receipt childReceipt + if err := readJSON(filepath.Join(a.Dir, "do.json"), &receipt); err != nil || receipt.OK == nil && receipt.Stop == "" && receipt.Deliverable == "" { + _ = signalChildGroup(pid, true) + _ = cmd.Wait() + e.mu.Unlock() + return fmt.Errorf("could not record the task's child: %w", identityErr) + } + } + err = a.UpdateState(func(s *State) { + s.PID = pid + s.ChildStartedAt = e.clock.Now() + s.ProcessStart = birth + s.CommandHash = hash + }) + if err == nil && !a.State().StopRequestedAt.IsZero() { + err = signalChildGroup(pid, false) + } + e.mu.Unlock() + if err != nil { + _ = signalChildGroup(pid, true) + _ = cmd.Wait() + return err + } + // Only Wait reaps our own child. An adopter polls the same pid without + // trying to wait on a process that belongs to a previous serving process. + done := make(chan int, 1) + go func() { + err := cmd.Wait() + code := 0 + if err != nil { + code = 1 + var exited *exec.ExitError + if errors.As(err, &exited) { + code = exited.ExitCode() + } + } + done <- code + }() + return e.follow(ctx, a, j, done) +} + +func (e *ChildExecutor) Adopt(ctx context.Context, a *Attempt, j Journal) error { + if err := e.attach(a); err != nil { + return err + } + defer e.detach(a) + if a.State().PID == 0 && !a.State().LaunchStartedAt.IsZero() { + // A child waits at do's hidden launch gate until its pid is durable. + // Discovery closes the crash window between Start and that state write. + pid, birth, hash, err := findAttemptChild(filepath.Join(a.Dir, "work"), filepath.Join(a.Dir, "run")) + if err == nil && pid > 0 { + if err := a.UpdateState(func(s *State) { + s.PID = pid + s.ProcessStart = birth + s.CommandHash = hash + s.ChildStartedAt = e.clock.Now() + }); err != nil { + return err + } + } + } + if !a.State().StopRequestedAt.IsZero() && e.alive(a) { + _ = signalChildGroup(a.State().PID, false) + } + return e.follow(ctx, a, j, nil) +} + +// Stop's timestamp survives a serving restart; the adopter owes the same +// thirty-second grace rather than granting an unresponsive child a new one. +func (e *ChildExecutor) Stop(id string) error { + e.mu.Lock() + defer e.mu.Unlock() + e.stops[id] = true + a := e.active[id] + if a == nil { + return nil + } + if err := a.UpdateState(func(s *State) { + if s.StopRequestedAt.IsZero() { + s.StopRequestedAt = e.clock.Now() + } + }); err != nil { + return err + } + if e.alive(a) { + return signalChildGroup(a.State().PID, false) + } + return nil +} + +func (e *ChildExecutor) alive(a *Attempt) bool { + s := a.State() + if s.PID <= 0 || s.ProcessStart == "" || s.CommandHash == "" { + return false + } + birth, hash, err := childIdentity(s.PID, filepath.Join(a.Dir, "work"), filepath.Join(a.Dir, "run")) + return err == nil && birth == s.ProcessStart && hash == s.CommandHash +} + +func (e *ChildExecutor) follow(ctx context.Context, a *Attempt, j Journal, done <-chan int) (followErr error) { + defer func() { + if followErr != nil && ctx.Err() == nil && e.alive(a) { + e.endUnfollowedChild(ctx, a, done) + } + }() + follower, err := newChildFollower(a, e.config.Ledger) + if err != nil { + return err + } + beat := e.clock.NewTicker(childPoll) + defer beat.Stop() + for { + if err := ctx.Err(); err != nil { + return err + } + if err := follower.poll(j); err != nil { + return err + } + var code *int + ended := false + if done != nil { + select { + case exit := <-done: + code = &exit + ended = true + default: + } + } else { + ended = !e.alive(a) + code = a.State().ExitCode + } + if ended { + if done != nil { + if err := a.UpdateState(func(s *State) { s.ExitCode = code }); err != nil { + return err + } + } + if err := follower.poll(j); err != nil { + return err + } + return e.finish(ctx, a, j, code, done == nil) + } + state := a.State() + if !state.StopRequestedAt.IsZero() && e.clock.Now().Sub(state.StopRequestedAt) >= ChildStopGrace && e.alive(a) { + _ = signalChildGroup(state.PID, true) + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-beat.C(): + } + } +} + +// A broken trajectory or journal cannot free admission while its child keeps +// spending. Reporting failures stop and join that child before Serve records +// an incomplete ending; ordinary serving shutdown still leaves it adoptable. +func (e *ChildExecutor) endUnfollowedChild(ctx context.Context, a *Attempt, done <-chan int) { + started := e.clock.Now() + if e.alive(a) { + _ = signalChildGroup(a.State().PID, false) + } + beat := e.clock.NewTicker(childPoll) + defer beat.Stop() + for e.alive(a) { + if e.clock.Now().Sub(started) >= ChildStopGrace { + _ = signalChildGroup(a.State().PID, true) + } + select { + case <-ctx.Done(): + return + case <-done: + return + case <-beat.C(): + } + } + if done != nil { + select { + case <-done: + case <-ctx.Done(): + } + } +} + +type childReceipt struct { + OK *bool `json:"ok"` + Stop string `json:"stop"` + Answer string `json:"answer"` + Deliverable string `json:"deliverable"` + Error string `json:"error"` + BlockedOn string `json:"blocked_on"` + Incomplete string `json:"incomplete"` + Model string `json:"model"` + Files []string `json:"files"` +} + +func (e *ChildExecutor) finish(ctx context.Context, a *Attempt, j Journal, code *int, adopted bool) error { + var receipt childReceipt + err := readJSON(filepath.Join(a.Dir, "do.json"), &receipt) + complete := err == nil && (receipt.OK != nil || receipt.Stop != "" || receipt.Deliverable != "") + end := Finished{Outcome: "incomplete", Model: receipt.Model} + for _, path := range receipt.Files { + if filepath.IsAbs(path) { + relative, err := filepath.Rel(filepath.Join(a.Dir, "work"), path) + if err != nil { + continue + } + path = relative + } + if path != "." && filepath.IsLocal(path) { + end.Changed = append(end.Changed, filepath.ToSlash(path)) + } + } + sort.Strings(end.Changed) + if receipt.Answer != "" { + end.Report = receipt.Answer + } else { + end.Report = receipt.Deliverable + } + if end.Report == "" { + end.Report = firstWords(receipt.Error, receipt.BlockedOn, receipt.Incomplete, "the task ended without an answer") + } + if complete { + status := 1 + if code != nil { + status = *code + } else { + switch receipt.Stop { + case "done": + status = 0 + case "budget", "turn-cap", "deadline", "price": + status = 3 + default: + if receipt.OK != nil && *receipt.OK { + status = 0 + } + } + } + switch status { + case 0: + end.Outcome = "done" + case 3, 124: + end.Outcome = "stopped" + } + } else if adopted { + end.Outcome = "interrupted" + end.Report = "the task was interrupted on this computer before it left an answer" + } + if !a.State().StopRequestedAt.IsZero() { + end.Outcome = "stopped" + } + if _, err := os.Stat(filepath.Join(a.Dir, "work")); err == nil { + result, err := SendResult(ctx, e.config.Copies, filepath.Join(a.Dir, "work"), a.Request.Input) + if ctx.Err() != nil { + return ctx.Err() + } + if err != nil { + end.Report += "\nThe work could not be sent back: " + err.Error() + } else { + end.Result = &result + } + } + return j.Append(end) +} +func firstWords(words ...string) string { + for _, s := range words { + if strings.TrimSpace(s) != "" { + return s + } + } + return "" +} + +type childFollower struct { + attempt *Attempt + ledger string + offsets map[string]int64 + nextOrder uint64 + pending map[uint64]Step + sessions map[string]bool + headers map[string]bool +} + +func newChildFollower(a *Attempt, ledger string) (*childFollower, error) { + f := &childFollower{attempt: a, ledger: ledger, offsets: map[string]int64{"usage": a.State().LedgerOffset}, nextOrder: 1, pending: map[uint64]Step{}, sessions: map[string]bool{}, headers: map[string]bool{}} + events, err := readEvents(a.Dir) + if err != nil { + return nil, err + } + for _, event := range events { + var mark struct { + Source string `json:"source"` + Offset int64 `json:"offset"` + } + if err := event.Decode(&mark); err != nil { + return nil, err + } + if mark.Source != "" && mark.Offset > f.offsets[mark.Source] { + f.offsets[mark.Source] = mark.Offset + } + if event.Type == "step" { + var line Step + if err := event.Decode(&line); err != nil { + return nil, err + } + if line.Step.RecordedOrder >= f.nextOrder { + f.nextOrder = line.Step.RecordedOrder + 1 + } + } + } + return f, nil +} + +type pendingStep struct { + step Step + at time.Time +} + +func (f *childFollower) poll(j Journal) error { + paths, err := filepath.Glob(filepath.Join(f.attempt.Dir, "run", "tasks", "*", "trajectory.jsonl")) + if err != nil { + return err + } + var pending []pendingStep + for _, path := range paths { + rel, _ := filepath.Rel(filepath.Join(f.attempt.Dir, "run"), path) + info, err := os.Stat(path) + if err != nil { + return err + } + err = f.lines(path, rel, func(raw []byte, offset int64) error { + var step run.Step + if err := json.Unmarshal(raw, &step); err != nil { + return err + } + at := step.RecordedAt + if at.IsZero() { + at = info.ModTime() + } + line := Step{Step: step, Source: rel, Offset: offset} + if step.RecordedOrder > 0 { + if step.RecordedOrder >= f.nextOrder { + f.pending[step.RecordedOrder] = line + } + } else { + pending = append(pending, pendingStep{line, at}) + } + return nil + }) + if err != nil { + return err + } + } + sort.SliceStable(pending, func(i, k int) bool { return pending[i].at.Before(pending[k].at) }) + for _, p := range pending { + if err := j.Append(p.step); err != nil { + return err + } + f.offsets[p.step.Source] = p.step.Offset + } + for { + line, ok := f.pending[f.nextOrder] + if !ok { + break + } + if err := j.Append(line); err != nil { + return err + } + f.offsets[line.Source] = line.Offset + delete(f.pending, f.nextOrder) + f.nextOrder++ + } + return f.lines(f.ledger, "usage", func(raw []byte, offset int64) error { + var line struct { + At time.Time `json:"at"` + Model string `json:"model"` + Seat string `json:"seat"` + Session string `json:"session"` + Input int `json:"in"` + Output int `json:"out"` + USD float64 `json:"usd"` + } + if err := json.Unmarshal(raw, &line); err != nil { + return err + } + // The child's own worker headers identify its provider sessions. A + // different conversation opened in the same folder is not its bill. + if line.Session != "" { + if _, known := f.sessions[line.Session]; !known { + if err := f.learnSessions(); err != nil { + return err + } + if _, known := f.sessions[line.Session]; !known { + f.sessions[line.Session] = false + } + } + } + if f.sessions[line.Session] { + if err := j.Append(Spend{Line: SpendLine{Model: line.Model, Seat: line.Seat, InputTokens: line.Input, OutputTokens: line.Output, USD: line.USD}, Source: "usage", Offset: offset}); err != nil { + return err + } + } + f.offsets["usage"] = offset + return nil + }) +} + +func (f *childFollower) learnSessions() error { + paths, err := filepath.Glob(filepath.Join(f.attempt.Dir, "run", "tasks", "*", "*_worker.jsonl")) + if err != nil { + return err + } + for _, path := range paths { + if f.headers[path] { + continue + } + file, err := os.Open(path) + if err != nil { + return err + } + raw, readErr := bufio.NewReader(file).ReadBytes('\n') + closeErr := file.Close() + if readErr != nil && !errors.Is(readErr, io.EOF) { + return readErr + } + if closeErr != nil { + return closeErr + } + var header struct { + Type string `json:"type"` + ID string `json:"id"` + } + // Another worker can still be creating its first line. Its provider + // call cannot precede that header, so leave partial headers to retry. + if json.Unmarshal(raw, &header) != nil || header.Type != "session" || header.ID == "" { + continue + } + f.sessions[header.ID] = true + f.headers[path] = true + } + return nil +} + +// lines ignores an unfinished final write and advances only after its event +// is durable. The journal itself is the cursor, closing the crash window +// between copying an event and saving a separate offset file. +func (f *childFollower) lines(path, source string, visit func([]byte, int64) error) error { + file, err := os.Open(path) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return err + } + defer file.Close() + offset := f.offsets[source] + if _, err := file.Seek(offset, io.SeekStart); err != nil { + return err + } + reader := bufio.NewReader(file) + for { + raw, err := reader.ReadBytes('\n') + if errors.Is(err, io.EOF) { + return nil + } + if err != nil { + return err + } + offset += int64(len(raw)) + if len(raw) > MaxFrameBytes { + return fmt.Errorf("the task's saved event is too large") + } + if len(bytes.TrimSpace(raw)) == 0 { + continue + } + if err := visit(raw, offset); err != nil { + return err + } + } +} diff --git a/internal/taskdoor/executor_test.go b/internal/taskdoor/executor_test.go new file mode 100644 index 0000000000..03634cfda8 --- /dev/null +++ b/internal/taskdoor/executor_test.go @@ -0,0 +1,508 @@ +//go:build linux || darwin + +package taskdoor_test + +import ( + "context" + "encoding/json" + "fmt" + "math" + "net" + "os" + "os/exec" + "os/signal" + "path/filepath" + "strings" + "syscall" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/run" + "github.com/Agent-Field/codeaf/internal/taskdoor" +) + +// The scripted child exercises the real process road without buying a provider +// call. All control files and its home belong to this attempt's fixture. +func TestTaskChildHelper(t *testing.T) { + if os.Getenv("CODEAF_TASK_CHILD_HELPER") != "1" { + return + } + args := os.Args + value := func(flag string) string { + for i, arg := range args { + if arg == flag && i+1 < len(args) { + return args[i+1] + } + } + return "" + } + work, store := value("--dir"), value("--task-store") + if work == "" || store == "" { + os.Exit(1) + } + var request taskdoor.Start + requestRaw, err := os.ReadFile(filepath.Join(filepath.Dir(store), "request.json")) + if err != nil || json.Unmarshal(requestRaw, &request) != nil || args[len(args)-1] != request.Instruction { + os.Exit(1) + } + for _, arg := range args { + if arg == "--yes-spend" { + os.Exit(1) + } + } + if value("--timeout") != taskdoor.DefaultTaskTimeout.String() { + os.Exit(1) + } + if pgid, err := syscall.Getpgid(os.Getpid()); err != nil || pgid != os.Getpid() { + os.Exit(1) + } + for _, stream := range []*os.File{os.Stdout, os.Stderr} { + if info, err := stream.Stat(); err != nil || !info.Mode().IsRegular() { + os.Exit(1) + } + } + _ = os.MkdirAll(filepath.Join(store, "tasks", "1"), 0700) + _ = os.MkdirAll(filepath.Join(store, "tasks", "2"), 0700) + header, _ := os.Create(filepath.Join(store, "tasks", "2", "fixture_worker.jsonl")) + _ = json.NewEncoder(header).Encode(map[string]string{"type": "session", "id": "child-session"}) + _ = header.Close() + f, _ := os.OpenFile(filepath.Join(filepath.Dir(store), "starts"), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600) + _, _ = fmt.Fprintln(f, os.Getpid()) + _ = f.Close() + steps, _ := os.Create(filepath.Join(store, "tasks", "2", "trajectory.jsonl")) + if os.Getenv("CODEAF_TASK_CHILD_MODE") == "corrupt" { + _, _ = steps.WriteString("unreadable step\n") + } else { + _ = json.NewEncoder(steps).Encode(run.Step{Kind: "step", Step: 1, Command: "first", RecordedOrder: 1}) + } + _ = steps.Close() + ledger, _ := os.OpenFile(os.Getenv("CODEAF_TASK_CHILD_LEDGER"), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600) + _ = json.NewEncoder(ledger).Encode(map[string]any{"session": "another-session", "workspace": work, "model": "other-model", "calls": 1, "usd": 9.0}) + _ = json.NewEncoder(ledger).Encode(map[string]any{"session": "child-session", "workspace": work, "model": "test-model", "seat": "worker", "calls": 1, "usd": 0.125, "in": 20, "out": 5}) + _ = ledger.Close() + signals := make(chan os.Signal, 1) + if os.Getenv("CODEAF_TASK_CHILD_MODE") == "deaf" { + signal.Ignore(os.Interrupt) + } else { + signal.Notify(signals, os.Interrupt) + } + _ = os.WriteFile(filepath.Join(filepath.Dir(store), "ready"), []byte("ready"), 0600) + stopped := false + for { + if _, err := os.Stat(filepath.Join(filepath.Dir(store), "finish")); err == nil { + break + } + select { + case <-signals: + stopped = true + case <-time.After(10 * time.Millisecond): + } + if stopped { + break + } + } + if os.Getenv("CODEAF_TASK_CHILD_DIE") == "1" { + os.Exit(9) + } + if os.Getenv("CODEAF_TASK_CHILD_MODE") == "late" { + for { + if _, err := os.Stat(filepath.Join(filepath.Dir(store), "allow-exit")); err == nil { + break + } + time.Sleep(10 * time.Millisecond) + } + } + steps, _ = os.Create(filepath.Join(store, "tasks", "1", "trajectory.jsonl")) + _ = json.NewEncoder(steps).Encode(run.Step{Kind: "step", Step: 2, Command: "second", RecordedOrder: 2}) + _ = steps.Close() + ledger, _ = os.OpenFile(os.Getenv("CODEAF_TASK_CHILD_LEDGER"), os.O_APPEND|os.O_WRONLY, 0600) + _ = json.NewEncoder(ledger).Encode(map[string]any{"session": "child-session", "workspace": work, "model": "test-model", "seat": "worker", "calls": 1, "usd": 0.375, "in": 30, "out": 7}) + _ = ledger.Close() + _ = os.WriteFile(filepath.Join(work, "child.txt"), []byte("the child worked"), 0640) + stop, code := "done", 0 + if stopped { + stop, code = "incomplete", 2 + } + _ = json.NewEncoder(os.Stdout).Encode(map[string]any{"ok": !stopped, "stop": stop, "answer": "the child's report", "spend_usd": 0.5, "model": "test-model", "files": []string{"child.txt"}}) + os.Exit(code) +} + +type childRig struct { + r *rig + copies taskdoor.CopyDeps + input taskdoor.Input + ledger, dir, childHome string + id string + mode string + clock taskdoor.Clock +} + +func newChildRig(t *testing.T) *childRig { + t.Helper() + a, b := copyHomes(t) + in, err := taskdoor.SendCopy(context.Background(), a, copyRepo(t)) + if err != nil { + t.Fatal(err) + } + r := newRig(t) + short, err := os.MkdirTemp("", "rn-l1-") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.RemoveAll(short) }) + return &childRig{r: r, copies: b, input: in, ledger: filepath.Join(short, "usage.jsonl"), childHome: short, id: attempt(70), dir: filepath.Join(r.root, attempt(70))} +} + +func (c *childRig) config(t *testing.T, die bool) taskdoor.Config { + t.Helper() + config := c.r.config() + config.Executor = taskdoor.NewExecutor(taskdoor.ExecutorConfig{Copies: c.copies, Ledger: c.ledger, Clock: c.clock, + Command: func(args []string) *exec.Cmd { + cmd := exec.Command(os.Args[0], append([]string{"-test.run=^TestTaskChildHelper$", "--"}, args...)...) + cmd.Env = append(os.Environ(), "HOME="+c.childHome, "CODEAF_HOME="+c.childHome, "CODEAF_TASK_CHILD_HELPER=1", "CODEAF_TASK_CHILD_LEDGER="+c.ledger, "CODEAF_TASK_CHILD_MODE="+c.mode, fmt.Sprintf("CODEAF_TASK_CHILD_DIE=%d", map[bool]int{true: 1}[die])) + return cmd + }}) + return config +} + +// Contract 12 also covers a crash after launch intent but before the pid +// rename: the waiting child is found by its attempt paths, never restarted. +func TestAnAdoptedLaunchIntentFindsItsChild(t *testing.T) { + c := newChildRig(t) + stop := c.r.serve(c.config(t, false)) + conn := c.start(t) + _ = conn.Close() + stop() + path := filepath.Join(c.dir, "state.json") + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var state taskdoor.State + if err := json.Unmarshal(raw, &state); err != nil { + t.Fatal(err) + } + state.PID = 0 + state.ProcessStart = "" + state.CommandHash = "" + raw, err = json.Marshal(state) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, raw, 0600); err != nil { + t.Fatal(err) + } + c.r.serve(c.config(t, false)) + _ = os.WriteFile(filepath.Join(c.dir, "finish"), nil, 0600) + _, _, done := childFinish(t, c.r.dial(), c.id) + if done.Outcome != "done" { + t.Fatalf("launch was not adopted: %+v", done) + } + starts, _ := os.ReadFile(filepath.Join(c.dir, "starts")) + if len(strings.Fields(string(starts))) != 1 { + t.Fatalf("child restarted: %q", starts) + } +} + +// A follower error cannot report completion and release a running slot while +// its child is still making provider calls outside the saved journal. +func TestAFollowingErrorStopsTheChildBeforeFinishing(t *testing.T) { + c := newChildRig(t) + c.mode = "corrupt" + c.r.serve(c.config(t, false)) + conn := c.r.dial() + req := request() + req.Input = c.input + req.Instruction = "--help" + send(t, conn, c.id, req) + receive(t, conn, "started") + _, _, done := childFinish(t, conn, c.id) + if done.Outcome != "incomplete" { + t.Fatalf("finish: %+v", done) + } + raw, err := os.ReadFile(filepath.Join(c.dir, "state.json")) + if err != nil { + t.Fatal(err) + } + var state taskdoor.State + if err := json.Unmarshal(raw, &state); err != nil { + t.Fatal(err) + } + if err := syscall.Kill(state.PID, 0); err == nil { + t.Fatal("unfollowed child still lives after finished") + } +} + +type childGraceClock struct{ *manualClock } + +func (c childGraceClock) NewTicker(d time.Duration) taskdoor.Ticker { + if d < time.Second { + d = time.Second + } + return c.manualClock.NewTicker(d) +} + +// DESIGN section 3.4 gives an interrupt thirty seconds to land partial work; +// an unresponsive process is then killed without waiting thirty real seconds. +func TestAChildIgnoringStopIsKilledAfterItsGrace(t *testing.T) { + c := newChildRig(t) + c.mode = "deaf" + clock := newClock() + c.clock = childGraceClock{clock} + c.r.serve(c.config(t, false)) + conn := c.start(t) + clock.awaitTicker(t) + send(t, conn, c.id, taskdoor.Stop{}) + receive(t, conn, "stopping") + clock.advance(taskdoor.ChildStopGrace - time.Second) + raw, err := os.ReadFile(filepath.Join(c.dir, "state.json")) + if err != nil { + t.Fatal(err) + } + var state taskdoor.State + if err := json.Unmarshal(raw, &state); err != nil { + t.Fatal(err) + } + if err := syscall.Kill(state.PID, 0); err != nil { + t.Fatal("child killed before grace", err) + } + clock.advance(time.Second) + _, _, done := childFinish(t, conn, c.id) + if done.Outcome != "stopped" { + t.Fatalf("finish: %+v", done) + } +} +func (c *childRig) start(t *testing.T) *taskdoor.Conn { + t.Helper() + conn := c.r.dial() + req := request() + req.Input = c.input + send(t, conn, c.id, req) + receive(t, conn, "started") + untilFile(t, filepath.Join(c.dir, "ready")) + return conn +} +func untilFile(t *testing.T, path string) { + t.Helper() + deadline := time.Now().Add(45 * time.Second) + for time.Now().Before(deadline) { + if _, err := os.Stat(path); err == nil { + return + } + if raw, err := os.ReadFile(filepath.Join(filepath.Dir(path), "result.json")); err == nil { + t.Fatalf("child ended before ready: %s", raw) + } + time.Sleep(10 * time.Millisecond) + } + t.Fatalf("did not write %s", path) +} +func childFinish(t *testing.T, conn *taskdoor.Conn, id string) ([]taskdoor.Step, []taskdoor.Spend, taskdoor.Finished) { + t.Helper() + send(t, conn, id, taskdoor.Watch{}) + var steps []taskdoor.Step + var spend []taskdoor.Spend + ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) + defer cancel() + stop := context.AfterFunc(ctx, func() { _ = conn.Close() }) + defer stop() + for { + f, err := conn.Receive() + if err != nil { + t.Fatal(err) + } + switch f.Type { + case "step": + var s taskdoor.Step + decode(t, f, &s) + steps = append(steps, s) + case "spend": + var s taskdoor.Spend + decode(t, f, &s) + spend = append(spend, s) + case "finished": + var done taskdoor.Finished + decode(t, f, &done) + return steps, spend, done + case "phase": + default: + t.Fatalf("unexpected event %s", f.Payload) + } + } +} + +// Contract 12: a serving restart follows the surviving process and replays +// each journaled step and charge once, without re-running the command. +func TestAnAdoptedChildIsFollowedNotRestarted(t *testing.T) { + c := newChildRig(t) + stop := c.r.serve(c.config(t, false)) + conn := c.start(t) + send(t, conn, c.id, taskdoor.Watch{}) + receive(t, conn, "step") + _ = conn.Close() + stop() + var saved taskdoor.State + raw, err := os.ReadFile(filepath.Join(c.dir, "state.json")) + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(raw, &saved); err != nil { + t.Fatal(err) + } + if saved.ProcessStart == "" || saved.CommandHash == "" { + t.Fatalf("child identity missing: %+v", saved) + } + c.r.serve(c.config(t, false)) + if err := os.WriteFile(filepath.Join(c.dir, "finish"), nil, 0600); err != nil { + t.Fatal(err) + } + steps, spend, done := childFinish(t, c.r.dial(), c.id) + if len(steps) != 2 || steps[0].Step.Command != "first" || steps[1].Step.Command != "second" { + t.Fatalf("steps: %+v, finish: %+v, state: %+v", steps, done, saved) + } + if len(spend) != 2 || done.Outcome != "done" || done.Result == nil || done.Model != "test-model" { + t.Fatalf("finish: %+v, spend=%+v", done, spend) + } + starts, err := os.ReadFile(filepath.Join(c.dir, "starts")) + if err != nil || len(strings.Fields(string(starts))) != 1 { + t.Fatalf("child restarted: %q, %v", starts, err) + } +} + +// Contract 12: a dead child without a complete receipt is interrupted; a +// serving restart must never repeat the tools it may already have run. +func TestAChildThatDiedWithoutAnAnswerIsInterrupted(t *testing.T) { + c := newChildRig(t) + stop := c.r.serve(c.config(t, true)) + conn := c.start(t) + _ = conn.Close() + stop() + var state taskdoor.State + raw, err := os.ReadFile(filepath.Join(c.dir, "state.json")) + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(raw, &state); err != nil { + t.Fatal(err) + } + if err := syscall.Kill(state.PID, syscall.SIGKILL); err != nil { + t.Fatal(err) + } + c.r.serve(c.config(t, true)) + _, _, done := childFinish(t, c.r.dial(), c.id) + if done.Outcome != "interrupted" { + t.Fatalf("finish: %+v", done) + } + starts, _ := os.ReadFile(filepath.Join(c.dir, "starts")) + if len(strings.Fields(string(starts))) != 1 { + t.Fatalf("child restarted: %q", starts) + } +} + +// Contract 5: the origin receives one charge per provider call, and the sum +// is the child's own receipt rather than an estimate from step counts. +func TestRemoteSpendSumsToTheRunsOwnBill(t *testing.T) { + c := newChildRig(t) + c.r.serve(c.config(t, false)) + conn := c.start(t) + _ = os.WriteFile(filepath.Join(c.dir, "finish"), nil, 0600) + _, spend, _ := childFinish(t, conn, c.id) + var bill struct { + Spend float64 `json:"spend_usd"` + } + raw, err := os.ReadFile(filepath.Join(c.dir, "do.json")) + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(raw, &bill); err != nil { + t.Fatal(err) + } + var sum float64 + for _, s := range spend { + sum += s.Line.USD + } + if len(spend) != 2 || math.Abs(sum-bill.Spend) > 1e-9 { + t.Fatalf("spend=%+v sum=%g bill=%g", spend, sum, bill.Spend) + } +} + +// DESIGN section 4's stop road must interrupt the child's group, keep its +// partial work and publish a stopped result that can still come home. +func TestAStopEndsTheChildAndFinishesStopped(t *testing.T) { + c := newChildRig(t) + c.r.serve(c.config(t, false)) + conn := c.start(t) + send(t, conn, c.id, taskdoor.Stop{}) + receive(t, conn, "stopping") + _, _, done := childFinish(t, conn, c.id) + if done.Outcome != "stopped" || done.Result == nil { + t.Fatalf("finish: %+v", done) + } + if _, err := os.Stat(filepath.Join(c.dir, "work", "child.txt")); err != nil { + t.Fatal("partial work was lost", err) + } +} + +// Contracts 11 and 12: abandonment remains durable while the child lands its +// interrupt, and its final writes cannot recreate a broken attempt on restart. +func TestAnAbandonedRealChildIsRemovedAfterItStops(t *testing.T) { + c := newChildRig(t) + c.mode = "late" + stop := c.r.serve(c.config(t, false)) + conn := c.start(t) + send(t, conn, c.id, taskdoor.Ack{Disposition: "abandoned"}) + receive(t, conn, "acked") + _ = conn.Close() + stop() + c.r.serve(c.config(t, false)) + if err := os.WriteFile(filepath.Join(c.dir, "allow-exit"), nil, 0600); err != nil { + t.Fatal(err) + } + deadline := time.Now().Add(10 * time.Second) + for { + if _, err := os.Stat(c.dir); os.IsNotExist(err) { + break + } + if time.Now().After(deadline) { + t.Fatal("abandoned child left an attempt folder") + } + time.Sleep(10 * time.Millisecond) + } + conn = c.r.dial() + send(t, conn, c.id, taskdoor.Watch{}) + receive(t, conn, "unknown") +} + +// Contract 3's ignore rule applies to local capture checks too: a build's +// ignored socket is neither task input nor a reason to refuse the transfer. +func TestIgnoredRuntimeFilesDoNotPreventSendingATaskCopy(t *testing.T) { + a, b := copyHomes(t) + local := copyRepo(t) + if err := os.MkdirAll(filepath.Join(local, "ignored"), 0700); err != nil { + t.Fatal(err) + } + // A short socket path avoids platform limits even with a long test name. + socketDir, err := os.MkdirTemp("", "rn-l1-") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(socketDir) + listener, err := net.Listen("unix", filepath.Join(socketDir, "socket")) + if err != nil { + t.Fatal(err) + } + defer listener.Close() + if err := os.Rename(filepath.Join(socketDir, "socket"), filepath.Join(local, "ignored", "socket")); err != nil { + t.Fatal(err) + } + in, err := taskdoor.SendCopy(t.Context(), a, local) + if err != nil { + t.Fatal(err) + } + dest := filepath.Join(t.TempDir(), "work") + if err := taskdoor.ReceiveCopy(t.Context(), b, in, dest); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(dest, "ignored")); !os.IsNotExist(err) { + t.Fatalf("ignored runtime files traveled: %v", err) + } +} diff --git a/internal/taskdoor/process_darwin.go b/internal/taskdoor/process_darwin.go new file mode 100644 index 0000000000..3a4fab99bd --- /dev/null +++ b/internal/taskdoor/process_darwin.go @@ -0,0 +1,45 @@ +package taskdoor + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "os/exec" + "strconv" + "strings" +) + +func childIdentity(pid int, work, record string) (string, string, error) { + raw, err := exec.Command("ps", "-ww", "-p", strconv.Itoa(pid), "-o", "lstart=,stat=,command=").Output() + if err != nil { + return "", "", err + } + fields := strings.Fields(string(raw)) + if len(fields) < 7 || strings.HasPrefix(fields[5], "Z") || !strings.Contains(string(raw), work) || !strings.Contains(string(raw), record) { + return "", "", errors.New("the recorded process is not this task's child") + } + sum := sha256.Sum256([]byte(strings.Join(fields[6:], " "))) + return strings.Join(fields[:5], " "), hex.EncodeToString(sum[:]), nil +} + +func findAttemptChild(work, record string) (int, string, string, error) { + raw, err := exec.Command("ps", "-axo", "pid=,command=").Output() + if err != nil { + return 0, "", "", err + } + for _, line := range strings.Split(string(raw), "\n") { + fields := strings.Fields(line) + if len(fields) < 2 || !strings.Contains(line, work) || !strings.Contains(line, record) { + continue + } + pid, err := strconv.Atoi(fields[0]) + if err != nil { + continue + } + birth, hash, err := childIdentity(pid, work, record) + if err == nil { + return pid, birth, hash, nil + } + } + return 0, "", "", errors.New("this task's child is no longer running") +} diff --git a/internal/taskdoor/process_linux.go b/internal/taskdoor/process_linux.go new file mode 100644 index 0000000000..63f51e1892 --- /dev/null +++ b/internal/taskdoor/process_linux.go @@ -0,0 +1,69 @@ +package taskdoor + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "errors" + "os" + "path/filepath" + "strconv" + "strings" +) + +func childIdentity(pid int, work, record string) (string, string, error) { + root := filepath.Join("/proc", strconv.Itoa(pid)) + raw, err := os.ReadFile(filepath.Join(root, "cmdline")) + if err != nil { + return "", "", err + } + workArg, recordArg := false, false + for _, arg := range bytes.Split(raw, []byte{0}) { + if bytes.Equal(arg, []byte(work)) { + workArg = true + } + if bytes.Equal(arg, []byte(record)) { + recordArg = true + } + } + if !workArg { + return "", "", errors.New("the recorded process does not name this task's work folder") + } + if !recordArg { + return "", "", errors.New("the recorded process does not name this task's private record") + } + stat, err := os.ReadFile(filepath.Join(root, "stat")) + if err != nil { + return "", "", err + } + // The command name may contain spaces and parentheses. Fields after its + // last ')' start at field 3; field 22 is the kernel's process birth tick. + end := strings.LastIndexByte(string(stat), ')') + if end < 0 { + return "", "", errors.New("the child's process start is not readable") + } + fields := strings.Fields(string(stat[end+1:])) + if len(fields) <= 19 || fields[0] == "Z" { + return "", "", os.ErrNotExist + } + sum := sha256.Sum256(raw) + return fields[19], hex.EncodeToString(sum[:]), nil +} + +func findAttemptChild(work, record string) (int, string, string, error) { + entries, err := os.ReadDir("/proc") + if err != nil { + return 0, "", "", err + } + for _, entry := range entries { + pid, err := strconv.Atoi(entry.Name()) + if err != nil { + continue + } + birth, hash, err := childIdentity(pid, work, record) + if err == nil { + return pid, birth, hash, nil + } + } + return 0, "", "", os.ErrNotExist +} diff --git a/internal/taskdoor/process_other.go b/internal/taskdoor/process_other.go new file mode 100644 index 0000000000..a413ac6868 --- /dev/null +++ b/internal/taskdoor/process_other.go @@ -0,0 +1,20 @@ +//go:build !linux && !darwin + +package taskdoor + +import ( + "errors" + "os/exec" +) + +func separateProcessGroup(*exec.Cmd) {} +func signalChildGroup(int, bool) error { + return errors.New(UnsupportedComputerLine) +} +func childIdentity(int, string, string) (string, string, error) { + return "", "", errors.New(UnsupportedComputerLine) +} + +func findAttemptChild(string, string) (int, string, string, error) { + return 0, "", "", errors.New(UnsupportedComputerLine) +} diff --git a/internal/taskdoor/process_unix.go b/internal/taskdoor/process_unix.go new file mode 100644 index 0000000000..a2202e73e4 --- /dev/null +++ b/internal/taskdoor/process_unix.go @@ -0,0 +1,17 @@ +//go:build linux || darwin + +package taskdoor + +import ( + "os/exec" + "syscall" +) + +func separateProcessGroup(cmd *exec.Cmd) { cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} } +func signalChildGroup(pid int, force bool) error { + signal := syscall.SIGINT + if force { + signal = syscall.SIGKILL + } + return syscall.Kill(-pid, signal) +} diff --git a/internal/taskdoor/serve.go b/internal/taskdoor/serve.go index ae6641d920..4a23b7ffef 100644 --- a/internal/taskdoor/serve.go +++ b/internal/taskdoor/serve.go @@ -45,6 +45,11 @@ func Serve(ctx context.Context, config Config) error { if config.Root == "" { return errors.New("taskdoor: an attempt store folder is required") } + root, err := filepath.Abs(config.Root) + if err != nil { + return err + } + config.Root = root if err := os.MkdirAll(config.Root, 0o700); err != nil { return err } @@ -248,7 +253,11 @@ func (d *door) execute(ctx context.Context, a *Attempt, adopt bool) { d.workers.Add(1) go func() { defer d.workers.Done() - defer d.store.released(a) + defer func() { + if ctx.Err() == nil { + d.store.released(a) + } + }() journal := attemptJournal{a: a} var err error if adopt { From 6ead5975ab120474594e215db8ca7c64935b2589 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Fri, 30 Oct 2026 18:33:33 -0400 Subject: [PATCH 04/34] config: remember which paired computer tasks run on A new task.runs_on row holds the device id of the paired computer a task goes to when nobody names one, so a rename does not break it; blank is this computer, which is the default and what every task did before. Co-Authored-By: Claude Opus 5.5 --- internal/config/settings.go | 36 ++++++++++++++++++++++++++++ internal/config/task_runs_on_test.go | 32 +++++++++++++++++++++++++ 2 files changed, 68 insertions(+) create mode 100644 internal/config/task_runs_on_test.go diff --git a/internal/config/settings.go b/internal/config/settings.go index 8f7d2c392c..10e7e06a07 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -1,6 +1,7 @@ package config import ( + "encoding/hex" "encoding/json" "fmt" "os" @@ -447,6 +448,13 @@ const ( // somewhere quieter. KeyTaskModel = "task.model" + // KeyTaskRunsOn remembers a device rather than its name, so a rename does + // not move the person's default. Blank keeps tasks on this computer. + KeyTaskRunsOn = "task.runs_on" + + // TaskRunsHere is the setting's empty value in the person's words. + TaskRunsHere = "this computer" + // KeyTaskSettle is WHO DECIDES a task that finished but that nobody could // check (internal/session's task_contract.go). It sits under `task.` beside // the audit row because it is the other end of that row's question: the audit @@ -2411,6 +2419,13 @@ func (s *Settings) build() []Setting { // how long you get to redirect it, who checks it, and whose hands it is // in — and none of them is a rule about how this conversation's own calls // are made. + Setting{ + Key: KeyTaskRunsOn, Category: CategoryTasks, Kind: SettingText, + Label: "Run tasks on", EmptyLabel: TaskRunsHere, + Hint: "The paired computer for tasks that work in their own copy. Leave it blank for " + TaskRunsHere + ".", + read: func() string { return TaskRunsOnAt(dir) }, + write: func(raw string) error { return WriteTaskRunsOn(dir, raw) }, + }, Setting{ Key: KeyTaskModel, Category: CategoryTasks, Kind: SettingText, Label: "task model", EmptyLabel: "the crew's worker", @@ -4313,6 +4328,27 @@ func TaskModelAt(profileDir string) string { return "" } +// TaskRunsOnAt reads the stable placement preference. A removed device is +// resolved to this computer by the session against its current roster. +func TaskRunsOnAt(profileDir string) string { + value, _ := persistedString(profileDir, KeyTaskRunsOn) + return strings.TrimSpace(value) +} + +// WriteTaskRunsOn accepts only a device identity. The computer picker resolves +// shown names before reaching this writer, keeping renames harmless. +func WriteTaskRunsOn(profileDir, raw string) error { + value := strings.TrimSpace(raw) + if value != "" { + hexID, ok := strings.CutPrefix(value, "dev_") + _, err := hex.DecodeString(hexID) + if !ok || len(hexID) != 32 || err != nil { + return fmt.Errorf("choose a paired computer or %s", TaskRunsHere) + } + } + return writeText(profileDir, KeyTaskRunsOn, value) +} + // ConsentTimeoutAt resolves the approval countdown, in seconds. 0 is a clock // that is off: the question waits from the start. A positive number is how // long the reminder runs before the card pauses; it never answers no. diff --git a/internal/config/task_runs_on_test.go b/internal/config/task_runs_on_test.go new file mode 100644 index 0000000000..55ee2368aa --- /dev/null +++ b/internal/config/task_runs_on_test.go @@ -0,0 +1,32 @@ +package config + +import "testing" + +// Contract 7 keeps placement stable when a paired computer is renamed. +func TestTaskRunsOnKeepsADeviceIDAndDefaultsToThisComputer(t *testing.T) { + dir := t.TempDir() + if got := TaskRunsOnAt(dir); got != "" { + t.Fatalf("default = %q", got) + } + settings := NewSettings(SettingsOptions{ProfileDir: dir}) + row, ok := settings.Row(KeyTaskRunsOn) + if !ok { + t.Fatal("missing Run tasks on setting") + } + if err := row.Apply("work computer"); err == nil { + t.Fatal("accepted a name instead of a device id") + } + const device = "dev_0123456789abcdef0123456789abcdef" + if err := row.Apply(device); err != nil { + t.Fatal(err) + } + if got := TaskRunsOnAt(dir); got != device { + t.Fatalf("saved = %q", got) + } + if err := row.Apply(""); err != nil { + t.Fatal(err) + } + if got := TaskRunsOnAt(dir); got != "" { + t.Fatalf("here = %q", got) + } +} From f49903bd2c5d1ee195f8d8c423fcc5d3e9584017 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Fri, 30 Oct 2026 18:33:33 -0400 Subject: [PATCH 05/34] session: run a task on a paired computer propose_task gains an optional computer argument, present only when this conversation has paired computers to send to; with none named, the Run tasks on setting decides, and a quick, in-place, program or multi-folder task always runs here. A remote placement uses a run engine that sends the task's copy, follows the other computer's steps and spend into this computer's plan store and ledger once, and lands the returned change through the local engine's existing road. Its record lives beside the run's trajectory, so a reopened conversation follows the same attempt from its last event instead of starting it again; a computer that stops answering is said within a minute, and run it here abandons the attempt and runs the same untouched copy locally. Co-Authored-By: Claude Opus 5.5 --- internal/session/beltfacts.go | 3 + internal/session/loop.go | 1 + internal/session/session.go | 4 + internal/session/sessionfile.go | 33 +- internal/session/stoprun.go | 4 +- internal/session/task.go | 40 +- internal/session/task_contract.go | 5 + internal/session/task_elsewhere.go | 209 ++++++ internal/session/task_elsewhere_test.go | 335 +++++++++ internal/session/task_index.go | 3 + internal/session/task_person.go | 2 +- internal/session/task_remote_control.go | 235 ++++++ internal/session/task_remote_money.go | 152 ++++ internal/session/task_remote_record.go | 369 ++++++++++ internal/session/task_remote_recovery_test.go | 697 ++++++++++++++++++ internal/session/task_remote_watch.go | 155 ++++ internal/session/task_run_admission.go | 5 + internal/session/task_run_belt.go | 167 ++++- internal/session/task_run_continue.go | 3 + internal/session/task_run_index.go | 1 + internal/session/task_run_recover.go | 52 +- internal/session/task_store.go | 10 +- internal/session/usage_ledger.go | 14 +- 23 files changed, 2461 insertions(+), 38 deletions(-) create mode 100644 internal/session/task_elsewhere.go create mode 100644 internal/session/task_elsewhere_test.go create mode 100644 internal/session/task_remote_control.go create mode 100644 internal/session/task_remote_money.go create mode 100644 internal/session/task_remote_record.go create mode 100644 internal/session/task_remote_recovery_test.go create mode 100644 internal/session/task_remote_watch.go diff --git a/internal/session/beltfacts.go b/internal/session/beltfacts.go index d1432de732..f2a545c24a 100644 --- a/internal/session/beltfacts.go +++ b/internal/session/beltfacts.go @@ -779,6 +779,9 @@ func promptWithBeltFacts(config Config) string { for strings.Contains(page, "\n\n\n") { page = strings.ReplaceAll(page, "\n\n\n", "\n\n") } + if config.TaskComputers != nil && config.mayProposeTask() { + page += "\n\nA task that works in its own copy can run on a paired computer. The optional `computer` argument on `propose_task` names its shown name; omitted uses Run tasks on, whose default is this computer. A remote task runs alone; wait for it to finish before proposing more work." + } return page } diff --git a/internal/session/loop.go b/internal/session/loop.go index 301e18a42d..48332d388c 100644 --- a/internal/session/loop.go +++ b/internal/session/loop.go @@ -4220,6 +4220,7 @@ func utf8RuneStart(b byte) bool { return b&0xC0 != 0x80 } // is not a call), whether this was a step of the conversation, and whether its // money arrived too late to belong to the turn now running. type bankedCall struct { + remote *remoteUsage used Usage model string role string diff --git a/internal/session/session.go b/internal/session/session.go index db8ccbe496..8eebe9c61b 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -1572,6 +1572,9 @@ type Config struct { // the same choices whichever way this is set. TaskSettle string + // TaskComputers is nil when this home cannot send tasks to paired computers. + TaskComputers TaskComputers + // Automations is the door to the automations store (automation_tool.go). // Nil is off: no `automation` tool and no card — which is what a run of an // automation is given, because nothing an automation does may arm another. @@ -2410,6 +2413,7 @@ type Config struct { // events — every Submit streams, whether it started the turn or steered it. // The methods live in agent.go; the loop they drive lives in loop.go. type Agent struct { + remoteAckJobs sync.WaitGroup knowledgeMu sync.RWMutex knowledgeRetired []*knowledgeJournal knowledgeVault *knowledge.Vault diff --git a/internal/session/sessionfile.go b/internal/session/sessionfile.go index d0f05f0197..42e94b7c80 100644 --- a/internal/session/sessionfile.go +++ b/internal/session/sessionfile.go @@ -870,15 +870,17 @@ type journalDivision struct { // counts every request to the provider, Turns only the ones a turn of the // person's made (see [Agent.addAuxiliaryUsage]). type journalUsage struct { - Model string `json:"model,omitempty"` - Input int `json:"input,omitempty"` - Output int `json:"output,omitempty"` - CacheRead int `json:"cacheRead,omitempty"` - CacheWrite int `json:"cacheWrite,omitempty"` - CostUSD float64 `json:"costUsd,omitempty"` - Calls int `json:"calls,omitempty"` - DurationMS int64 `json:"durationMs,omitempty"` - Aux bool `json:"aux,omitempty"` + // RemoteEvent makes mirrored charges idempotent across cursor replay. + RemoteEvent string `json:"remote_event,omitempty"` + Model string `json:"model,omitempty"` + Input int `json:"input,omitempty"` + Output int `json:"output,omitempty"` + CacheRead int `json:"cacheRead,omitempty"` + CacheWrite int `json:"cacheWrite,omitempty"` + CostUSD float64 `json:"costUsd,omitempty"` + Calls int `json:"calls,omitempty"` + DurationMS int64 `json:"durationMs,omitempty"` + Aux bool `json:"aux,omitempty"` // Empty marks a paid auxiliary call that reached its output ceiling without // returning any answer. Role says which errand it was; today only a reflex // writes the mark. @@ -1127,6 +1129,7 @@ func (p journalPart) placeholderBecause(reason string) ai.ContentPart { // lock orders the writes, this one keeps a write from being interleaved by // anything that reaches the file another way. type sessionFile struct { + remoteSeen map[string]bool // onWrite hears each line that reached the file, once the lock is released. onWrite func() mu sync.Mutex @@ -1724,6 +1727,7 @@ func openSessionFile(path, cwd, model, id string) (*sessionFile, replayedSession journal.captions = replayed.captions journal.tooks = replayed.tooks journal.restored = replayed.usage + journal.remoteSeen = replayed.remoteSeen if !replayed.existed { // The header names the session once. A resumed file keeps its @@ -1881,6 +1885,7 @@ func readJournal(reader io.Reader, path string, rebuild bool) (replayedSession, // compaction marker for the same reason the title does — where a picture came // from is a fact about the file, not about the tail of the transcript. images := make(map[string]string) + remoteSeen := make(map[string]bool) // And the note index with it, for the same reason and in the same pass: the // mark is on the LINE, and once the line has been rebuilt into a message // there is nothing left to read it off (see [sessionFile.notes]). @@ -2106,6 +2111,12 @@ func readJournal(reader io.Reader, path string, rebuild bool) (replayedSession, continue } used := entry.Usage + if used.RemoteEvent != "" { + if remoteSeen[used.RemoteEvent] { + continue + } + remoteSeen[used.RemoteEvent] = true + } spent.Input += used.Input spent.Output += used.Output spent.CacheRead += used.CacheRead @@ -2235,6 +2246,7 @@ func readJournal(reader io.Reader, path string, rebuild bool) (replayedSession, stopped = "" } return replayedSession{ + remoteSeen: remoteSeen, stopped: stopped, messages: messages, unread: unread, @@ -2392,7 +2404,8 @@ func legacyCompactionNote(summary string) string { // the name arrived here after the other two — and a reader of a call site // should not have to count positions to know which bool is which. type replayedSession struct { - messages []ai.Message + remoteSeen map[string]bool + messages []ai.Message // requests is what the file's request lines said, read beside the rebuild // for a page watching the work with no lane to it ([RequestBooks]). requests RequestBooks diff --git a/internal/session/stoprun.go b/internal/session/stoprun.go index 3fd4b422ff..21ed01f365 100644 --- a/internal/session/stoprun.go +++ b/internal/session/stoprun.go @@ -122,7 +122,9 @@ func (a *Agent) stopCurrentBeltRow(run *beltRun, id uint64, why string) (string, g.planNote("the run's stop could not be written: " + err.Error()) } } - if cut != nil { + if run.remoteEngine != nil { + a.stopRemoteRun(run) + } else if cut != nil { cut() } promise := "its branch is kept" diff --git a/internal/session/task.go b/internal/session/task.go index 5f75033834..bc5b82f02b 100644 --- a/internal/session/task.go +++ b/internal/session/task.go @@ -256,6 +256,7 @@ type taskArguments struct { Brief string `json:"brief"` Deliverable string `json:"deliverable"` Where string `json:"where"` + Computer string `json:"computer,omitempty"` Ground string `json:"ground"` WorkspaceSet *WorkspaceSetRequest `json:"workspace_set,omitempty"` Acceptance string `json:"acceptance"` @@ -293,6 +294,7 @@ type taskArguments struct { type taskSpec struct { workspaceSet *WorkspaceSetRequest title string + computer string // named says A MODEL ALREADY WROTE THIS TITLE as a name, rather than the // door cutting one out of a sentence it had to hand. It is the whole of what // decides whether the work is named again (taskname.go): a name the shaper @@ -606,6 +608,9 @@ func (a *Agent) taskTools() []bare.Tool { if a.config.mayDelegate() { schema = taskSchemaJSON } + if a.config.TaskComputers != nil { + schema = strings.Replace(schema, `"properties":{`, `"properties":{`+taskComputerSchema, 1) + } if WorkspaceOwnershipEnabled(a.config.Place) { schema = strings.Replace(schema, `"properties":{`, `"properties":{`+workspaceSetTaskSchema+`,`, 1) } @@ -714,6 +719,11 @@ func (a *Agent) stageTask(ctx context.Context, args json.RawMessage) bare.Staged } return bare.Settled(problem, true) } + if spec.computer != "" { + if _, err := a.resolveTaskComputer(ctx, spec.computer, taskStand{}, "", false); err != nil { + return bare.Settled(err.Error(), true) + } + } // THE DOOR REFUSALS, before a card or a slot. A proposal that left out the // program the person named, a trivial ask and a depends_on that can never // resolve are all "do not start this"; they live in one helper so this @@ -938,7 +948,11 @@ func proposalBrief(spec taskSpec, via *delegate.Delegate) string { if via != nil && via.Words { return programWords(spec.brief, *via) } - return composeBrief(briefWhole, spec.request, spec.brief, spec.deliverable, spec.acceptance, "", spec.admission, spec.origin, taskCopy{}) + brief := composeBrief(briefWhole, spec.request, spec.brief, spec.deliverable, spec.acceptance, "", spec.admission, spec.origin, taskCopy{}) + if len(spec.checks) > 0 { + brief += "\n\nChecks\n\n" + strings.Join(spec.checks, "\n") + } + return brief } // commitProposalToRun is the run road of an approved proposal: an approved @@ -963,7 +977,7 @@ func (a *Agent) commitProposalToRun(ctx context.Context, p *stagedProposal, spec return runDidNotStart(p.id, err), true, true } - if !(bashBeltAsked() || spec.via != "") || chatRunEngine == nil || a.config.InTask { + if !(bashBeltAsked() || spec.via != "" || a.taskComputerPreference() || (a.config.TaskComputers != nil && spec.computer != "")) || chatRunEngine == nil || a.config.InTask { return "", false, false } a.mu.Lock() @@ -977,6 +991,24 @@ func (a *Agent) commitProposalToRun(ctx context.Context, p *stagedProposal, spec } via = &m } + placementStand := stand + // The explicit in-place request controls computer placement even when the + // repository's existing isolation law still prepares a local branch. + if strings.EqualFold(strings.TrimSpace(spec.where), "in place") { + placementStand.mode = TaskModeInPlace + } + placement, err := a.resolveTaskComputer(ctx, spec.computer, placementStand, spec.kind(), via != nil) + if err != nil { + return err.Error(), true, true + } + placement.model = namedModel(spec) + placement.effort = string(spec.crewEffort) + if spec.thinking != effort.None { + placement.effort = spec.thinking.String() + } + placement.maxSteps, placement.noProgress = spec.maxSteps, spec.noProgress + ctx = withTaskPlacement(ctx, placement) + elsewhere = withReport(elsewhere, placement.sentence) description := proposalBrief(spec, via) // THE RUN OUTLIVES THE TURN THAT LAUNCHED IT, AND NOT THE CONVERSATION. // This context is the turn's, and the turn cancels it on its way out @@ -1139,7 +1171,8 @@ func parseTaskArguments(args json.RawMessage) (taskSpec, string) { return taskSpec{}, "Invalid arguments: " + err.Error() } spec := taskSpec{ - title: strings.TrimSpace(parsed.Title), + title: strings.TrimSpace(parsed.Title), + computer: strings.TrimSpace(parsed.Computer), // THE MODEL GROOMED THIS PROPOSAL AND NAMED IT IN THE SAME BREATH, so the // namer leaves it alone (taskname.go). named: strings.TrimSpace(parsed.Title) != "", @@ -1564,6 +1597,7 @@ func newTaskQuestion(id uint64, spec taskSpec, elsewhere string, deadline time.T hold: make(chan struct{}), notice: TaskNotice{ ID: id, + Computer: spec.computer, Title: spec.title, Summary: spec.summary, Brief: spec.brief, diff --git a/internal/session/task_contract.go b/internal/session/task_contract.go index 0f6f15ab08..3292007b04 100644 --- a/internal/session/task_contract.go +++ b/internal/session/task_contract.go @@ -451,6 +451,11 @@ func settleOrAsk(word string) TaskSettle { // the top half, an update the bottom, and no surface reads a field its kind // did not set. type TaskNotice struct { + // Placement is separate from the state word: empty Computer means here. + Computer string + Unreachable bool + RanElsewhere bool + Remote *TaskRemoteRecord // Thinking is the effective setup, including a saved continuation choice. Thinking string // ID is the proposal's token: a surface hands it back to diff --git a/internal/session/task_elsewhere.go b/internal/session/task_elsewhere.go new file mode 100644 index 0000000000..64c8cf1b2f --- /dev/null +++ b/internal/session/task_elsewhere.go @@ -0,0 +1,209 @@ +package session + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "sort" + "strings" + "time" + + "github.com/Agent-Field/codeaf/internal/config" +) + +// TaskComputerControlWithin bounds best-effort stop and acknowledgement calls. +const TaskComputerControlWithin = 10 * time.Second + +// TaskComputer is a roster entry whose name can change without changing a +// saved preference. An unsupported target carries the sentence explaining why. +type TaskComputer struct { + Device string + Name string + CanTakeTasks bool + Reason string +} + +// TaskComputers is absent where paired task placement cannot work. Session +// owns this boundary because the transport is built on the local run engine. +type TaskComputers interface { + List(context.Context) ([]TaskComputer, error) + ThisComputer() string + Engine(context.Context, string) (TaskRunEngine, error) +} + +// TaskRunEngine extends the ordinary engine only for a remote attempt's +// lifetime. Landing always belongs to the registered local engine. +type TaskRunEngine interface { + RunEngine + Resume(context.Context, RunSpec) RunSummary + Stop(context.Context, TaskRemoteRecord) error + Abandon(context.Context, TaskRemoteRecord) error + Acknowledge(context.Context, TaskRemoteRecord) error + CheckCopy(context.Context, string, string) error +} + +// TaskComputerRecovery builds a watcher from an already admitted identity, +// even while the directory is unreachable. Network checks belong in the watch. +type TaskComputerRecovery interface { + RecoverEngine(TaskExecutor) TaskRunEngine +} + +func (a *Agent) taskExecutorEngine(ctx context.Context, executor TaskExecutor) (TaskRunEngine, error) { + if computers, ok := a.config.TaskComputers.(TaskComputerRecovery); ok { + return computers.RecoverEngine(executor), nil + } + return a.config.TaskComputers.Engine(ctx, executor.Device) +} + +type TaskExecutor struct { + Device string `json:"device"` + Name string `json:"name"` +} + +// TaskRemoteRecord is additive to the run row. Missing records describe local +// work; abandoned records fence late results before a local replacement starts. +type TaskRemoteRecord struct { + NamedModel string `json:"named_model,omitempty"` + NamedEffort string `json:"named_effort,omitempty"` + MaxSteps int `json:"max_steps,omitempty"` + NoProgress int `json:"no_progress,omitempty"` + Executor TaskExecutor `json:"executor"` + Attempt string `json:"attempt,omitempty"` + Fingerprint string `json:"fingerprint,omitempty"` + Seq uint64 `json:"seq,omitempty"` + USD float64 `json:"usd,omitempty"` + StartFailed bool `json:"start_failed,omitempty"` + Unreachable bool `json:"unreachable,omitempty"` + Abandoned bool `json:"abandoned,omitempty"` + Finished bool `json:"finished,omitempty"` + Settled bool `json:"settled,omitempty"` + AckPending bool `json:"ack_pending,omitempty"` + Final json.RawMessage `json:"final,omitempty"` + Restored bool `json:"restored,omitempty"` +} + +type taskPlacement struct { + computer *TaskComputer + sentence string + model, effort string + maxSteps, noProgress int +} +type taskPlacementKey struct{} + +func withTaskPlacement(ctx context.Context, p taskPlacement) context.Context { + return context.WithValue(ctx, taskPlacementKey{}, p) +} +func taskPlacementOf(ctx context.Context) taskPlacement { + p, _ := ctx.Value(taskPlacementKey{}).(taskPlacement) + return p +} + +const taskComputerSchema = `"computer":{"type":"string","description":"The paired computer to run the task on, by its shown name. Omitted means the Run tasks on setting decides; that is ` + config.TaskRunsHere + ` unless the person changed it. Only a task that works in its own copy can go."},` + +// resolveTaskComputer runs once at admission, before a copy or a join. A stale +// preference is harmless, while an explicit misspelling is a correctable error. +func (a *Agent) resolveTaskComputer(ctx context.Context, named string, stand taskStand, kind TaskKind, program bool) (taskPlacement, error) { + p := taskPlacementOf(ctx) + named = strings.TrimSpace(named) + if a.config.TaskComputers == nil { + return p, nil + } + explicit := named != "" + if explicit && (strings.EqualFold(named, "here") || strings.EqualFold(named, config.TaskRunsHere) || strings.EqualFold(named, a.config.TaskComputers.ThisComputer())) { + return p, nil + } + preference := config.TaskRunsOnAt(a.config.ProfileDir) + if !explicit && preference == "" { + return p, nil + } + computers, err := a.config.TaskComputers.List(ctx) + if err != nil { + return p, err + } + for _, computer := range computers { + if explicit && strings.EqualFold(strings.TrimSpace(computer.Name), named) || !explicit && preference != "" && computer.Device == preference { + if !computer.CanTakeTasks { + if explicit { + p.sentence = computer.Reason + } + return p, nil + } + reason := "" + switch { + case kind == TaskKindQuick: + reason = "a quick task" + case program: + reason = "a program run" + case !taskCopyCanTravel(stand): + reason = "this task" + } + if reason != "" { + if explicit { + p.sentence = reason + " runs here — only a task that works in its own copy can run on " + computer.Name + } + return p, nil + } + p.computer = &computer + return p, nil + } + } + if explicit { + names := make([]string, 0, len(computers)) + for _, c := range computers { + names = append(names, c.Name) + } + sort.Strings(names) + if len(names) == 0 { + names = append(names, config.TaskRunsHere) + } + return p, fmt.Errorf("no paired computer is named %q — your computers are: %s", named, strings.Join(names, ", ")) + } + return p, nil +} + +// StartTaskOn is the shared session entry for a person's explicit placement. +// Model proposals reach the same resolver through their normal admission road. +func (a *Agent) StartTaskOn(ctx context.Context, brief string, solo bool, computer string) (uint64, string, string, error) { + stand := a.taskGroundOrStandingIn(taskSpec{title: taskPersonTitle(brief), request: brief, brief: brief, acceptance: taskPersonAcceptance}) + p, err := a.resolveTaskComputer(ctx, computer, stand, "", false) + if err != nil { + return 0, "", "", err + } + var id uint64 + var title, note string + if p.computer != nil { + id, title, note, err = a.startTaskRun(withTaskPlacement(context.WithoutCancel(ctx), p), brief, solo, "") + } else { + id, title, note, err = a.StartTask(withTaskPlacement(ctx, p), brief, solo) + } + return id, title, withReport(note, p.sentence), err +} + +var errRemotePlacementBusy = errors.New("the work already underway uses another computer; let it finish before starting this task") + +// cloneTaskRemote keeps published rows immutable while the watcher advances. +func cloneTaskRemote(r *TaskRemoteRecord) *TaskRemoteRecord { + if r == nil { + return nil + } + copy := *r + copy.Final = append(json.RawMessage(nil), r.Final...) + return © +} + +func (a *Agent) taskComputerPreference() bool { + return a.config.TaskComputers != nil && config.TaskRunsOnAt(a.config.ProfileDir) != "" +} + +// A private singleton uses the same carrier as an ordinary repository copy. +// Its folder mirror also has the independent history prepared by that road. +func taskCopyCanTravel(stand taskStand) bool { + if stand.mode == TaskModeInPlace || stand.mode == TaskModeReference || stand.mode == TaskModeFolder { + return false + } + if stand.workspaceSet != nil { + return len(stand.workspaceSet.Roots) == 1 && (stand.mode == TaskModeWorktree || stand.mode == TaskModeMirror) + } + return stand.mode == TaskModeWorktree +} diff --git a/internal/session/task_elsewhere_test.go b/internal/session/task_elsewhere_test.go new file mode 100644 index 0000000000..90ea7f9222 --- /dev/null +++ b/internal/session/task_elsewhere_test.go @@ -0,0 +1,335 @@ +package session + +import ( + "context" + "encoding/json" + "errors" + "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/crewroute" + "github.com/Agent-Field/codeaf/internal/plandb" + "path/filepath" + "strings" + "testing" + "time" +) + +// Contract 6 makes a misspelt computer repairable at the tool boundary. +func TestAnUnknownComputerNameIsAToolErrorListingTheComputers(t *testing.T) { + a, _ := newTestAgent(t, &scriptedCompleter{}, func(c *Config) { c.TaskComputers = testTaskComputers{} }) + result, refused, err := a.stageTask(context.Background(), json.RawMessage(`{"title":"Repair export","summary":"Repair the export path","brief":"Fix the export failure in the project","deliverable":"working export","acceptance":"export succeeds","computer":"missing"}`)).Commit(context.Background()) + if err != nil || !refused || !strings.Contains(result, `no paired computer is named "missing"`) || !strings.Contains(result, "work computer") { + t.Fatalf("tool result = %q, %v, %v", result, refused, err) + } +} + +// The absence law keeps an unusable argument off every unpaired belt. +func TestTheComputerArgumentIsAbsentWhenNothingIsPaired(t *testing.T) { + a, _ := newTestAgent(t, &scriptedCompleter{}, nil) + if _, present := schemaProperties(t, a.taskTools()[0].Schema)["computer"]; present { + t.Fatal("unpaired schema offers computer") + } + +} + +type testTaskComputers struct{ engine TaskRunEngine } + +func (testTaskComputers) List(context.Context) ([]TaskComputer, error) { + return []TaskComputer{{Device: "dev_0123456789abcdef0123456789abcdef", Name: "work computer", CanTakeTasks: true}}, nil +} +func (testTaskComputers) ThisComputer() string { return "chat computer" } +func (c testTaskComputers) Engine(context.Context, string) (TaskRunEngine, error) { + return c.engine, nil +} + +// The fake stands at the process/network boundary; local copies, stores, +// notices and landing remain the conversation's real roads. +type scriptedTaskEngine struct { + start func(context.Context, RunSpec) RunSummary + resume func(context.Context, RunSpec) RunSummary + check func(context.Context, string, string) error + stop func() + abandon func() +} + +func (e *scriptedTaskEngine) Start(ctx context.Context, spec RunSpec) RunSummary { + return e.start(ctx, spec) +} +func (e *scriptedTaskEngine) Resume(ctx context.Context, spec RunSpec) RunSummary { + if e.resume != nil { + return e.resume(ctx, spec) + } + return e.start(ctx, spec) +} +func (e *scriptedTaskEngine) Land(context.Context, *plandb.Store, string, string, string) (RunLanding, error) { + panic("a remote engine must never land here") +} +func (e *scriptedTaskEngine) Stop(context.Context, TaskRemoteRecord) error { + if e.stop != nil { + e.stop() + } + return nil +} +func (e *scriptedTaskEngine) Abandon(context.Context, TaskRemoteRecord) error { + if e.abandon != nil { + e.abandon() + } + return nil +} +func (e *scriptedTaskEngine) Acknowledge(context.Context, TaskRemoteRecord) error { return nil } +func (e *scriptedTaskEngine) CheckCopy(ctx context.Context, path, fingerprint string) error { + if e.check != nil { + return e.check(ctx, path, fingerprint) + } + return nil +} + +func remoteTaskAgent(t *testing.T, remote TaskRunEngine, options func(*Config)) (*Agent, *beltRunDouble) { + t.Helper() + t.Setenv("CODEAF_TASK_BELT", "bash") + local := newBeltRunDouble("done here") + local.real = true + local.honoursStop = true + registerBeltRunEngine(t, local) + place, repo := t.TempDir(), beltRunCommittedRepo(t) + a, _ := newTestAgent(t, beltRunCompleter{text: "done"}, func(c *Config) { + c.Workspace = repo + c.Place = Place{Dir: place} + c.SessionFile = c.Place.Transcript() + c.ProfileDir = t.TempDir() + c.AskConsent = false + c.TaskComputers = testTaskComputers{engine: remote} + if options != nil { + options(c) + } + }) + return a, local +} +func remoteRecordFor(spec RunSpec) TaskRemoteRecord { + r := *spec.Remote + r.Attempt = "att_0123456789abcdef" + r.Fingerprint = "sent-copy" + return r +} +func waitRemoteRun(t *testing.T, a *Agent) { + t.Helper() + beltRunWaitFor(t, "remote task to settle", func() bool { a.beltMu.Lock(); defer a.beltMu.Unlock(); return a.beltRun == nil }) +} +func approveRemoteProposal(t *testing.T, a *Agent, args taskArguments) (string, bool) { + t.Helper() + raw, _ := json.Marshal(args) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + events, stop := a.WatchTaskUpdates() + defer stop() + done := make(chan struct{}) + defer close(done) + go func() { + for { + select { + case <-done: + return + case event, ok := <-events: + if !ok { + return + } + if event.Task != nil && event.Kind == EventTaskProposal { + a.ResolveTask(event.Task.ID, TaskAnswer{Approved: true}) + } + } + } + }() + answer, refused, err := a.stageTask(ctx, raw).Commit(ctx) + if err != nil { + t.Fatal(err) + } + return answer, refused +} +func taskOnOtherComputer() taskArguments { + return taskArguments{Title: "Repair export", Summary: "Repair the export failure", Brief: "Repair the export path in this project", Deliverable: "a working export", Acceptance: "the export regression passes", Checks: []string{"go test ./export"}, Computer: " WORK COMPUTER "} +} + +// Contract 6 sends the same settled instruction a local root worker would read. +func TestAProposalNamingAPairedComputerRunsThere(t *testing.T) { + reached := make(chan RunSpec, 1) + release := make(chan struct{}) + remote := &scriptedTaskEngine{start: func(ctx context.Context, spec RunSpec) RunSummary { + r := remoteRecordFor(spec) + _ = spec.SaveRemote(r, true) + reached <- spec + select { + case <-release: + case <-ctx.Done(): + } + _ = spec.Store.CompleteRoot("done there") + return RunSummary{Outcome: "done", Result: "done there"} + }} + a, local := remoteTaskAgent(t, remote, func(c *Config) { + c.RouteCrew = func(config.CrewAsk) (crewroute.Decision, error) { + t.Error("remote work consulted the origin's crew") + return crewroute.Decision{}, errors.New("origin crew must stay unused") + } + }) + args := taskOnOtherComputer() + args.Model = "test/model" + args.Effort = "cheap" + args.MaxSteps = 17 + args.NoProgress = 9 + answer, refused := approveRemoteProposal(t, a, args) + if refused { + t.Fatalf("proposal refused: %s", answer) + } + spec := <-reached + if spec.NamedModel != "test/model" || spec.NamedEffort != "cheap" || spec.StepsPerTask != 17 || spec.NoProgress != 9 { + t.Fatalf("named controls = %+v", spec) + } + if !strings.Contains(spec.Brief, "a working export") || !strings.Contains(spec.Brief, "the export regression passes") || !strings.Contains(spec.Brief, "go test ./export") { + t.Fatalf("instruction lost its contract: %s", spec.Brief) + } + if local.didRun() { + t.Fatal("local model ran the remote task") + } + close(release) + waitRemoteRun(t, a) + if local.lands() != 1 { + t.Fatalf("local landing count = %d", local.lands()) + } +} + +// Contract 7 uses the stable preference only when the proposal omits placement. +func TestTheRunTasksOnSettingSendsATaskProposedWithoutAComputer(t *testing.T) { + reached := make(chan struct{}, 1) + remote := &scriptedTaskEngine{start: func(ctx context.Context, spec RunSpec) RunSummary { + reached <- struct{}{} + _ = spec.Store.CompleteRoot("done there") + return RunSummary{Outcome: "done"} + }} + a, local := remoteTaskAgent(t, remote, func(c *Config) { + if err := config.WriteTaskRunsOn(c.ProfileDir, "dev_0123456789abcdef0123456789abcdef"); err != nil { + t.Fatal(err) + } + }) + args := taskOnOtherComputer() + args.Computer = "" + answer, refused := approveRemoteProposal(t, a, args) + if refused { + t.Fatalf("proposal: %s", answer) + } + select { + case <-reached: + case <-time.After(5 * time.Second): + t.Fatal("setting did not send the task") + } + waitRemoteRun(t, a) + if local.didRun() { + t.Fatal("setting ran locally") + } +} + +// Contract 7 keeps quick work beside its caller even with a remote preference. +func TestAQuickTaskRunsHereEvenWhenTheSettingNamesAnotherComputer(t *testing.T) { + a, _ := remoteTaskAgent(t, &scriptedTaskEngine{start: func(context.Context, RunSpec) RunSummary { + t.Error("quick work reached the other computer") + return RunSummary{} + }}, func(c *Config) { _ = config.WriteTaskRunsOn(c.ProfileDir, "dev_0123456789abcdef0123456789abcdef") }) + // The quick tool has its ordinary legacy-node road even when placement + // preferences name another computer; it never reaches a remote RunEngine. + t.Setenv("CODEAF_TASK_BELT", "off") + answer, refused, quickErr := a.startQuickTask(context.Background(), json.RawMessage(`{"line":"Inspect the project","items":["Inspect the project"]}`)) + if quickErr != nil || refused { + t.Fatalf("quick tool: %s, %v", answer, quickErr) + } + node := a.graph().node(1) + waitDoneNode(t, node) + if node.spec.kind() != TaskKindQuick { + t.Fatal("quick tool did not create quick work") + } + p, err := a.resolveTaskComputer(context.Background(), "", taskStand{mode: TaskModeInPlace}, TaskKindQuick, false) + if err != nil || p.computer != nil || p.sentence != "" { + t.Fatalf("quick placement = %+v, %v", p, err) + } + p, err = a.resolveTaskComputer(context.Background(), "work computer", taskStand{mode: TaskModeInPlace}, TaskKindQuick, false) + if err != nil || p.computer != nil || !strings.Contains(p.sentence, "a quick task runs here") { + t.Fatalf("named quick placement = %+v, %v", p, err) + } +} + +// Contract 5 mirrors one paid call, skipping replay and the final-total fold. +func TestRemoteSpendLandsOnThisLedgerOnce(t *testing.T) { + ledger := filepath.Join(t.TempDir(), UsageLedgerName) + remote := &scriptedTaskEngine{start: func(ctx context.Context, spec RunSpec) RunSummary { + r := remoteRecordFor(spec) + _ = spec.SaveRemote(r, true) + charge := RunCharge{Model: "remote/model", TokensIn: 40, TokensOut: 8, USD: .25} + var current uint64 + calls := 0 + watch := func(ctx context.Context, from uint64, alive func(), deliver func(uint64) error) error { + calls++ + alive() + for _, seq := range []uint64{1, 1, 2} { + current = seq + if err := deliver(seq); err != nil { + return err + } + } + return ErrTaskWatchEnded + } + err := FollowTaskWatch(ctx, nil, 0, watch, func(seq uint64) error { + if current == 1 { + return spec.OnRemoteCharge(seq, charge, "worker") + } + return nil + }, func(bool) {}) + if err != nil { + t.Error(err) + } + _ = spec.Store.CompleteRoot("done") + return RunSummary{Outcome: "done", USD: .25} + }} + a, _ := remoteTaskAgent(t, remote, func(c *Config) { c.usageLedger = ledger }) + if _, _, _, err := a.StartTaskOn(context.Background(), "Repair export", false, "work computer"); err != nil { + t.Fatal(err) + } + waitRemoteRun(t, a) + FlushUsage() + lines, err := ReadUsage(ledger, time.Time{}) + if err != nil { + t.Fatal(err) + } + if len(lines) != 1 || lines[0].USD != .25 || lines[0].Computer != "work computer" || lines[0].Task != "1" || lines[0].Session != a.journalID() { + t.Fatalf("ledger = %+v", lines) + } + if got := a.Usage(); got.CostUSD != .25 || got.Calls != 1 || got.Input != 40 { + t.Fatalf("usage = %+v", got) + } +} + +// Contract 7 keeps an explicit in-place request here even when repository +// isolation preserves a local branch for that request. +func TestAnExplicitInPlaceTaskRunsHereEvenWhenAComputerWasNamed(t *testing.T) { + elsewhere := make(chan struct{}, 1) + remote := &scriptedTaskEngine{start: func(ctx context.Context, spec RunSpec) RunSummary { + elsewhere <- struct{}{} + _ = spec.Store.CompleteRoot("wrong computer") + return RunSummary{Outcome: "done"} + }} + a, local := remoteTaskAgent(t, remote, nil) + args := taskOnOtherComputer() + args.Where = "in place" + args.Deliverable = "a corrected " + filepath.Join(a.config.Workspace, "output.txt") + args.Brief = "Write the corrected output in " + filepath.Join(a.config.Workspace, "output.txt") + receipt, refused := approveRemoteProposal(t, a, args) + if refused { + t.Fatalf("proposal refused: %s", receipt) + } + select { + case <-elsewhere: + t.Fatal("explicit in-place task went to another computer") + case <-local.entered: + case <-time.After(5 * time.Second): + t.Fatal("local in-place task never started") + } + if !strings.Contains(receipt, "runs here") { + t.Fatalf("named computer had no local-placement explanation: %s", receipt) + } + endBeltRun(t, a, local) +} diff --git a/internal/session/task_index.go b/internal/session/task_index.go index 1fb94bfa9c..262b6c861f 100644 --- a/internal/session/task_index.go +++ b/internal/session/task_index.go @@ -115,6 +115,9 @@ const ( // and the cost, because those are the three questions asked about work that is // already over. type TaskIndexEntry struct { + Computer string `json:"computer,omitempty"` + Unreachable bool `json:"unreachable,omitempty"` + RanElsewhere bool `json:"ran_elsewhere,omitempty"` // ID is the node's id inside the session that ran it, decimal. It is NOT // unique across the file — ids restart with every conversation — which is // why SessionID sits beside it and why the pair is what identifies a row. diff --git a/internal/session/task_person.go b/internal/session/task_person.go index 379035a94e..f5a88d4208 100644 --- a/internal/session/task_person.go +++ b/internal/session/task_person.go @@ -82,7 +82,7 @@ func (a *Agent) StartTask(ctx context.Context, brief string, solo bool) (uint64, // The run road falls back here for every store it cannot serve, so the road // below is the whole of this door whenever the belt is not asked for — which // is every build and every caller that never asked. - if bashBeltAsked() { + if bashBeltAsked() || a.taskComputerPreference() { return a.startTaskRun(ctx, brief, solo, "") } return a.startTaskLegacy(ctx, brief, solo) diff --git a/internal/session/task_remote_control.go b/internal/session/task_remote_control.go new file mode 100644 index 0000000000..e78918128d --- /dev/null +++ b/internal/session/task_remote_control.go @@ -0,0 +1,235 @@ +package session + +import ( + "context" + "errors" + "fmt" + "strconv" + "time" + + "github.com/Agent-Field/codeaf/internal/plandb" +) + +// continueRemoteRun adopts the original plan and copy. A terminal plan may +// still need its received change landed after a crash, so no local supervisor +// is asked to resume it and no start can be issued by this road. +func (a *Agent) continueRemoteRun(ctx context.Context, kept TaskNotice) (string, error) { + if a.config.TaskComputers == nil { + return "", errors.New("paired task computers are unavailable here") + } + a.lockBeltStart() + defer a.beltStartMu.Unlock() + a.beltMu.Lock() + live := a.beltRun + a.beltMu.Unlock() + if live != nil { + return "", fmt.Errorf("%s is already running", taskStopName(live.row, live.title)) + } + g := a.graph() + store, err := plandb.Open(g.planPath(), "", "", "", "") + if err != nil { + return "", err + } + record := readRemoteRecord(store, kept.Remote) + if record == nil || record.Abandoned || record.StartFailed || record.Settled { + store.Close() + return "", errors.New("this task has no remote work to follow") + } + if store.RootID() != strconv.FormatUint(kept.ID, 10) { + store.Close() + return "", errors.New("this task's plan is no longer the current run") + } + engine, err := a.taskExecutorEngine(ctx, record.Executor) + if err != nil { + store.Close() + return "", err + } + tree, err := reopenWorkspaceSet(kept.Copy, kept.WorkspaceSet, a.config.Place) + if err != nil { + store.Close() + return "", err + } + runCtx, cut := context.WithCancel(a.rootContext()) + run := &beltRun{store: store, root: store.RootID(), row: kept.ID, title: kept.Title, workspace: tree.dir, ground: tree.ground, tree: tree, stand: taskStand{dir: tree.ground, mode: tree.mode}, cut: cut, born: kept.StartedAt, over: make(chan struct{}), remote: record, remoteEngine: engine, remoteResume: true} + // The plan state's handle is the one every page already reads. + run.plan = &planState{path: g.planPath(), chat: g.planChat()} + if root := store.Task(store.RootID()); root != nil && root.Status == plandb.StatusCancelled { + run.stopped = true + run.stopReason = root.Error + } + a.installBeltRun(g, run) + if run.stopped { + a.stopRemoteRun(run) + } + kept.State = TaskRunning + kept.Remote = cloneTaskRemote(record) + kept.Unreachable = record.Unreachable + a.publishRunRow(g, kept) + go a.driveBeltRun(runCtx, engine, run, a.beltRunSpec(run, "")) + return "following " + taskStopName(kept.ID, kept.Title) + " on " + record.Executor.Name, nil +} + +// RunTaskHere fences the old attempt before touching its network or starting +// local work. The copy comparison and result restore share remoteMu, so a late +// result cannot pass between the comparison and the abandonment write. +func (a *Agent) RunTaskHere(ctx context.Context, row uint64) (string, error) { + epoch, admissionErr := a.beltAdmissionEpoch() + if admissionErr != nil { + return "", admissionErr + } + if a.config.TaskComputers == nil || chatRunEngine == nil { + return "", errRunRoadUnavailable + } + a.lockBeltStart() + if err := a.checkBeltAdmission(epoch); err != nil { + a.beltStartMu.Unlock() + return "", err + } + g := a.graph() + kept, found := runRowOf(g, row) + if !found || kept.Remote == nil || kept.Remote.Abandoned { + a.beltStartMu.Unlock() + return "", errors.New("this task is not waiting on another computer") + } + a.beltMu.Lock() + live := a.beltRun + a.beltMu.Unlock() + if live != nil && live.row != row { + a.beltStartMu.Unlock() + return "", errors.New("another task is already running in this conversation") + } + var store *plandb.Store + var record *TaskRemoteRecord + var engine TaskRunEngine + var err error + if live != nil { + live.remoteMu.Lock() + record = cloneTaskRemote(live.remote) + engine = live.remoteEngine + store = live.store + } else { + store, err = openRemoteTaskPlan(g, row) + if err == nil { + record = readRemoteRecord(store, kept.Remote) + engine, err = a.taskExecutorEngine(ctx, record.Executor) + } + } + unlock := func() { + if live != nil { + live.remoteMu.Unlock() + } else if store != nil { + _ = store.Close() + } + a.beltStartMu.Unlock() + } + if err == nil && store.RootID() != strconv.FormatUint(row, 10) { + err = errors.New("this task's saved plan does not match its number") + } + if err != nil { + unlock() + return "", err + } + if record == nil || engine == nil || (!record.StartFailed && !(record.Unreachable && !record.Finished) && kept.State != TaskInterrupted) { + unlock() + return "", errors.New("the other computer is still answering; stop this task before running it here") + } + tree, err := reopenWorkspaceSet(kept.Copy, kept.WorkspaceSet, a.config.Place) + if err != nil { + unlock() + return "", err + } + if err = engine.CheckCopy(ctx, tree.dir, record.Fingerprint); err != nil { + unlock() + return "", err + } + record.Abandoned, record.AckPending = true, true + if err = writeRemoteRecord(store, *record); err != nil { + unlock() + return "", err + } + if live != nil { + live.remote = cloneTaskRemote(record) + } + kept.Remote = cloneTaskRemote(record) + kept.Computer = "" + kept.Unreachable = false + a.publishRunRow(g, kept) + brief := "" + if root := store.Task(store.RootID()); root != nil { + brief = root.Description + } + if live != nil { + live.remoteMu.Unlock() + } else if store != nil { + _ = store.Close() + } + // Keep start admission while joining the abandoned watcher. No new task may + // archive this plan or retire its copy between the fence and its replacement. + defer a.beltStartMu.Unlock() + // Network cleanup is best effort and cannot delay the person's local work. + go func() { + stopCtx, cancel := context.WithTimeout(context.Background(), TaskComputerControlWithin) + defer cancel() + _ = engine.Abandon(stopCtx, *record) + }() + if live != nil { + if live.cut != nil { + live.cut() + } + select { + case <-live.over: + case <-ctx.Done(): + return "", ctx.Err() + } + } + a.beltMu.Lock() + busy := a.beltRun != nil + a.beltMu.Unlock() + if busy { + return "", errors.New("another task started before this task could run here") + } + plan, newStore, err := a.openBeltRunStore(g, g.planPath(), strconv.FormatUint(row, 10), kept.Title, brief, false) + if err != nil { + return "", err + } + runCtx, cut := context.WithCancel(a.rootContext()) + run := &beltRun{plan: plan, store: newStore, root: newStore.RootID(), row: row, title: kept.Title, worker: record.NamedModel, workspace: tree.dir, ground: tree.ground, tree: tree, stand: taskStand{dir: tree.ground, mode: tree.mode}, cut: cut, born: a.taskClockNow(), over: make(chan struct{})} + a.installBeltRun(g, run) + kept.State, kept.Report, kept.Ending, kept.Stopped = TaskRunning, "", "", false + kept.StartedAt, kept.EndedAt = run.born, time.Time{} + kept.Computer, kept.Unreachable, kept.RanElsewhere = "", false, false + a.publishRunRow(g, kept) + go a.driveBeltRun(runCtx, chatRunEngine, run, a.beltRunSpec(run, brief)) + return "running " + taskStopName(row, kept.Title) + " here", nil +} + +func (a *Agent) stopRemoteRun(run *beltRun) { + run.remoteMu.Lock() + record := cloneTaskRemote(run.remote) + run.remoteMu.Unlock() + if record == nil { + return + } + go func() { + ctx, cancel := context.WithTimeout(context.Background(), TaskComputerControlWithin) + defer cancel() + _ = run.remoteEngine.Stop(ctx, *record) + }() +} + +// A previous refused run can have its plan archived by later work. Read the +// requested task's plan rather than borrowing the newest task's instruction. +func openRemoteTaskPlan(g *TaskGraph, row uint64) (*plandb.Store, error) { + paths := append([]string{g.planPath()}, planArchivePaths(g.planPath())...) + for _, path := range paths { + store, err := plandb.Open(path, "", "", "", "") + if err != nil { + continue + } + if store.RootID() == strconv.FormatUint(row, 10) { + return store, nil + } + _ = store.Close() + } + return nil, errors.New("this task's saved plan is no longer available") +} diff --git a/internal/session/task_remote_money.go b/internal/session/task_remote_money.go new file mode 100644 index 0000000000..d5a592eb84 --- /dev/null +++ b/internal/session/task_remote_money.go @@ -0,0 +1,152 @@ +package session + +import ( + "bufio" + "encoding/json" + "errors" + "io" + "os" + "path/filepath" + "sync" + + "github.com/Agent-Field/codeaf/internal/filelock" + "github.com/Agent-Field/codeaf/internal/trace" +) + +type remoteUsage struct { + Task, Computer, Event string + Seat Seat +} +type remoteLedgerCursor struct { + offset int64 + seen map[string]bool +} + +var remoteUsageMemo = struct { + sync.Mutex + ledgers map[string]*remoteLedgerCursor +}{ledgers: map[string]*remoteLedgerCursor{}} + +// Mirrored calls are paid elsewhere. Refreshing the appended tail under a +// process-shared lock prevents a second chat owner from repeating a paid call. +func appendRemoteUsage(path string, line UsageLine) error { + remoteUsageMemo.Lock() + defer remoteUsageMemo.Unlock() + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + return err + } + lock, err := os.OpenFile(path+".remote-lock", os.O_CREATE|os.O_RDWR, 0600) + if err != nil { + return err + } + defer lock.Close() + if err := filelock.Lock(lock, true, false); err != nil { + return err + } + defer filelock.Unlock(lock) + f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR|os.O_APPEND, 0600) + if err != nil { + return err + } + defer f.Close() + info, err := f.Stat() + if err != nil { + return err + } + memo := remoteUsageMemo.ledgers[path] + if memo == nil || info.Size() < memo.offset { + memo = &remoteLedgerCursor{seen: map[string]bool{}} + remoteUsageMemo.ledgers[path] = memo + } + if _, err := f.Seek(memo.offset, io.SeekStart); err != nil { + return err + } + reader := bufio.NewReader(f) + for { + raw, err := reader.ReadBytes('\n') + if err != nil { + if err != io.EOF { + return err + } + if len(raw) > 0 { + if _, err := f.Write([]byte{'\n'}); err != nil { + return err + } + } + break + } + memo.offset += int64(len(raw)) + var old UsageLine + if json.Unmarshal(raw, &old) == nil && old.RemoteEvent != "" { + memo.seen[old.RemoteEvent] = true + } + } + if memo.seen[line.RemoteEvent] { + return nil + } + raw, err := json.Marshal(line) + if err != nil { + return err + } + if _, err = f.Write(append(raw, '\n')); err != nil { + return err + } + if err = f.Sync(); err != nil { + return err + } + memo.seen[line.RemoteEvent] = true + // Leave offset at the last read boundary: another process or the ordinary + // asynchronous ledger writer may have appended while this call was written. + return nil +} + +// The transcript records the same event identity before changing live totals. +// A crash on either side of that write replays exactly one charge on reopen. +func (a *Agent) foldRemoteCharge(event string, charge RunCharge) error { + if charge.TokensIn == 0 && charge.TokensOut == 0 && charge.USD == 0 { + return nil + } + used := Usage{Input: charge.TokensIn, Output: charge.TokensOut, CacheRead: charge.Cached, Calls: 1, CostUSD: charge.USD} + if a.file != nil { + added, err := a.file.appendRemoteCharge(event, charge.Model, used) + if err != nil { + return err + } + if !added { + return nil + } + } + a.bank(bankedCall{used: used, model: charge.Model, late: true}) + return nil +} +func (s *sessionFile) appendRemoteCharge(event, model string, used Usage) (bool, error) { + s.mu.Lock() + if s.closed { + s.mu.Unlock() + return false, os.ErrClosed + } + if s.remoteSeen == nil { + s.remoteSeen = map[string]bool{} + } + if s.remoteSeen[event] { + s.mu.Unlock() + return false, nil + } + raw, err := json.Marshal(sessionEntry{Type: "usage", Timestamp: stamp(), Usage: &journalUsage{RemoteEvent: event, Model: model, Input: used.Input, Output: used.Output, CacheRead: used.CacheRead, CostUSD: used.CostUSD, Calls: used.Calls, Aux: true}}) + if err == nil { + _, err = s.file.Write(append(trace.ScrubRegistered(raw), '\n')) + } + if err == nil { + err = s.file.Sync() + } + if err != nil { + s.mu.Unlock() + return false, errors.New("the task's remote spending could not be saved: " + err.Error()) + } + s.remoteSeen[event] = true + s.mu.Unlock() + if s.onWrite != nil { + s.onWrite() + } + return true, nil +} diff --git a/internal/session/task_remote_record.go b/internal/session/task_remote_record.go new file mode 100644 index 0000000000..4063db714a --- /dev/null +++ b/internal/session/task_remote_record.go @@ -0,0 +1,369 @@ +package session + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strconv" + "time" + + "github.com/Agent-Field/codeaf/internal/plandb" +) + +var ErrTaskAttemptAbandoned = errors.New("this task attempt was abandoned") + +// Each plan's sidecar is authoritative for its remote cursor and fencing. The +// checkpoint projects it onto run rows for readers that do not open the plan. +func taskRemotePath(store *plandb.Store) string { + return filepath.Join(plandb.TaskDir(filepath.Dir(store.Path()), store.RootID()), "remote.json") +} +func writeRemoteRecord(store *plandb.Store, record TaskRemoteRecord) error { + raw, err := json.Marshal(record) + if err != nil { + return err + } + return atomicRemoteFile(taskRemotePath(store), append(raw, '\n')) +} +func atomicRemoteFile(path string, data []byte) error { + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + return err + } + file, err := os.CreateTemp(filepath.Dir(path), ".remote-*") + if err != nil { + return err + } + defer os.Remove(file.Name()) + if err = file.Chmod(0600); err == nil { + _, err = file.Write(data) + } + if err == nil { + err = file.Sync() + } + closeErr := file.Close() + if err == nil { + err = closeErr + } + if err != nil { + return err + } + if err := os.Rename(file.Name(), path); err != nil { + return err + } + directory, err := os.Open(filepath.Dir(path)) + if err != nil { + return err + } + defer directory.Close() + return directory.Sync() +} +func readRemoteRecord(store *plandb.Store, fallback *TaskRemoteRecord) *TaskRemoteRecord { + raw, err := os.ReadFile(taskRemotePath(store)) + if err != nil { + return cloneTaskRemote(fallback) + } + var r TaskRemoteRecord + if json.Unmarshal(raw, &r) != nil || r.Executor.Device == "" { + return cloneTaskRemote(fallback) + } + return &r +} + +func (a *Agent) placeBeltRun(ctx context.Context, engine RunEngine, run *beltRun) { + run.placement = taskPlacementOf(ctx) + wish := crewWishOf(ctx) + if run.placement.effort == "" { + run.placement.effort = string(wish.effort) + } + if run.placement.model == "" { + run.placement.model = wish.worker + } + if run.placement.computer == nil { + return + } + run.remoteEngine, _ = engine.(TaskRunEngine) + c := run.placement.computer + run.remote = &TaskRemoteRecord{Executor: TaskExecutor{Device: c.Device, Name: c.Name}, NamedModel: run.placement.model, NamedEffort: run.placement.effort, MaxSteps: run.placement.maxSteps, NoProgress: run.placement.noProgress} +} + +// remoteBeltSpec hands the adapter durable writes and local book ownership. +// It refuses every late callback after abandonment under the same copy lock. +func (a *Agent) remoteBeltSpec(run *beltRun, spec RunSpec) RunSpec { + if run.remoteEngine == nil { + return spec + } + run.remoteMu.Lock() + spec.Remote = cloneTaskRemote(run.remote) + run.remoteMu.Unlock() + spec.TaskNumber = run.row + spec.NamedModel, spec.NamedEffort = spec.Remote.NamedModel, spec.Remote.NamedEffort + if spec.Remote.MaxSteps > 0 { + spec.StepsPerTask = spec.Remote.MaxSteps + } + spec.NoProgress = spec.Remote.NoProgress + spec.SaveRemote = func(record TaskRemoteRecord, force bool) error { + run.remoteMu.Lock() + defer run.remoteMu.Unlock() + if run.remote != nil && run.remote.Abandoned { + return ErrTaskAttemptAbandoned + } + previous := run.remote + run.remote = cloneTaskRemote(&record) + if !force && time.Since(run.remoteSaved) < time.Second { + return nil + } + if err := writeRemoteRecord(run.store, record); err != nil { + run.remote = previous + return err + } + run.remoteSaved = time.Now() + a.publishRemoteRow(run) + return nil + } + spec.WithRemoteCopy = func(apply func() error) error { + run.remoteMu.Lock() + defer run.remoteMu.Unlock() + if run.remote.Abandoned { + return ErrTaskAttemptAbandoned + } + return apply() + } + steps := &remoteStepMirror{} + spec.OnRemoteStep = func(seq uint64, step json.RawMessage) error { + run.remoteMu.Lock() + defer run.remoteMu.Unlock() + if run.remote.Abandoned { + return ErrTaskAttemptAbandoned + } + return steps.append(run.store, run.remote.Attempt, seq, step) + } + spec.OnRemoteCharge = func(seq uint64, charge RunCharge, seat string) error { + run.remoteMu.Lock() + defer run.remoteMu.Unlock() + if run.remote.Abandoned { + return ErrTaskAttemptAbandoned + } + if err := a.recordUsageLine(bankedCall{model: charge.Model, used: Usage{Input: charge.TokensIn, Output: charge.TokensOut, CacheRead: charge.Cached, Calls: 1, CostUSD: charge.USD}, remote: &remoteUsage{Task: strconv.FormatUint(run.row, 10), Computer: run.remote.Executor.Name, Event: fmt.Sprintf("%s:%d", run.remote.Attempt, seq), Seat: Seat(seat)}}); err != nil { + return err + } + return a.foldRemoteCharge(fmt.Sprintf("%s:%d", run.remote.Attempt, seq), charge) + + } + spec.RemoteStopped = func() bool { stopped, _ := a.beltRunStopped(run); return stopped } + spec.OnRemotePhase = func(words string) { + a.emitTaskPhase(TaskPhaseNotice{ID: run.row, Phase: TaskPhaseWorking, Text: words}) + } + return spec +} + +// publishRemoteRow is called under remoteMu; the published value is a copy so +// a watcher cannot mutate a checkpoint while another writer serializes it. +func (a *Agent) publishRemoteRow(run *beltRun) { + g := a.graph() + if g == nil { + return + } + kept, ok := runRowOf(g, run.row) + if !ok { + return + } + kept.Remote = cloneTaskRemote(run.remote) + kept.Computer = run.remote.Executor.Name + kept.Unreachable = run.remote.Unreachable + kept.CostUSD = max(kept.CostUSD, run.remote.USD) + kept.RanElsewhere = run.remote.Finished && !run.remote.StartFailed + if run.remote.Unreachable { + kept.Doing = TaskComputerStoppedAnswering(kept.Computer) + } else if run.remote.StartFailed { + kept.Doing = kept.Report + } else if run.remote.Finished { + kept.Doing = TaskRanOn(kept.Computer) + } else { + kept.Doing = TaskRunningOn(kept.Computer) + } + a.publishRunRow(g, kept) +} + +// mirrorRemoteStep keeps remote output paths off local file actions. The +// remote sequence on each line makes replay after a checkpoint crash harmless. +type remoteStepMirror struct { + seen map[uint64]bool + readBytes, writeBytes int64 +} + +func (mirror *remoteStepMirror) append(store *plandb.Store, attempt string, seq uint64, raw json.RawMessage) error { + path := filepath.Join(plandb.TaskDir(filepath.Dir(store.Path()), store.RootID()), "trajectory.jsonl") + if mirror.seen == nil { + existing, err := os.ReadFile(path) + if err != nil && !os.IsNotExist(err) { + return err + } + mirror.readBytes += int64(len(existing)) + // An interrupted append cannot swallow the next complete step. + if len(existing) > 0 && existing[len(existing)-1] != '\n' { + end := bytes.LastIndexByte(existing, '\n') + 1 + if err := os.Truncate(path, int64(end)); err != nil { + return err + } + existing = existing[:end] + } + mirror.seen = map[uint64]bool{} + scan := bufio.NewScanner(bytes.NewReader(existing)) + scan.Buffer(make([]byte, 4096), 1<<20) + for scan.Scan() { + var line struct { + Attempt string `json:"remote_attempt"` + Seq uint64 `json:"remote_seq"` + } + if json.Unmarshal(scan.Bytes(), &line) == nil && line.Attempt == attempt { + mirror.seen[line.Seq] = true + } + } + if err := scan.Err(); err != nil { + return err + } + + } + if mirror.seen[seq] { + return nil + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(raw, &fields); err != nil { + return err + } + delete(fields, "full_output") + delete(fields, "children") + fields["kind"] = json.RawMessage(`"step"`) + fields["remote_attempt"], _ = json.Marshal(attempt) + fields["remote_seq"], _ = json.Marshal(seq) + line, err := json.Marshal(fields) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + return err + } + file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600) + if err != nil { + return err + } + n, err := file.Write(append(line, '\n')) + mirror.writeBytes += int64(n) + if err == nil { + err = file.Sync() + } + closed := file.Close() + if err == nil { + err = closed + } + if err == nil { + mirror.seen[seq] = true + } + return err +} + +// ackRemoteRun follows local settlement, so B cannot discard an unreceived +// change. A failed acknowledgement remains durable for the next reopen. +func (a *Agent) ackRemoteRun(run *beltRun) { + run.remoteMu.Lock() + if run.remote == nil || run.remote.Abandoned || run.remote.StartFailed || !run.remote.Finished { + run.remoteMu.Unlock() + return + } + record := *cloneTaskRemote(run.remote) + record.Settled, record.AckPending = true, true + if err := writeRemoteRecord(run.store, record); err != nil { + run.remoteMu.Unlock() + return + } + run.remote = &record + a.publishRemoteRow(run) + run.remoteMu.Unlock() + ctx, cancel := context.WithTimeout(context.Background(), TaskComputerControlWithin) + defer cancel() + if run.remoteEngine.Acknowledge(ctx, record) != nil { + return + } + run.remoteMu.Lock() + defer run.remoteMu.Unlock() + run.remote.AckPending = false + _ = writeRemoteRecord(run.store, *run.remote) + a.publishRemoteRow(run) +} + +// retryRemoteAck updates only the attempt metadata of the current row. Network +// latency must not let an old receipt overwrite a newer local task ending. +func (a *Agent) retryRemoteAck(g *TaskGraph, store *plandb.Store, kept TaskNotice) { + record := *cloneTaskRemote(kept.Remote) + a.remoteAckJobs.Add(1) + go func() { + defer a.remoteAckJobs.Done() + ctx, cancel := context.WithTimeout(a.rootContext(), TaskComputerControlWithin) + defer cancel() + engine, err := a.taskExecutorEngine(ctx, record.Executor) + if err != nil { + return + } + if record.Abandoned { + err = engine.Abandon(ctx, record) + } else { + err = engine.Acknowledge(ctx, record) + } + if err != nil { + return + } + record.AckPending = false + _ = writeRemoteRecord(store, record) + // Only metadata changes here. Publishing an earlier full row after a + // concurrent local completion could put its state back to running. + g.mu.Lock() + rows := g.runs[kept.ID] + for i := range rows { + if rows[i].ID == kept.ID && rows[i].Remote != nil && rows[i].Remote.Attempt == record.Attempt { + rows[i].Remote = cloneTaskRemote(&record) + } + } + g.runs[kept.ID] = rows + g.mu.Unlock() + g.checkpoint() + }() +} + +// Settled attempts can outlive the current plan after a lost acknowledgement. +// Reopening retries those saved obligations as well as the newest run's watch. +func (a *Agent) retrySavedRemoteAcks(g *TaskGraph) { + if a.config.TaskComputers == nil { + return + } + g.mu.Lock() + rows := g.runRowsLocked() + g.mu.Unlock() + for _, row := range rows { + if row.Remote == nil || !row.Remote.AckPending { + continue + } + store, err := openRemoteTaskPlan(g, row.ID) + if err != nil { + continue + } + record := readRemoteRecord(store, row.Remote) + if record != nil && record.AckPending { + row.Remote = record + a.retryRemoteAck(g, store, row) + } + _ = store.Close() + } +} + +func (run *beltRun) taskComputerName() string { + run.remoteMu.Lock() + defer run.remoteMu.Unlock() + if run.remote != nil && !run.remote.Abandoned { + return run.remote.Executor.Name + } + return "" +} diff --git a/internal/session/task_remote_recovery_test.go b/internal/session/task_remote_recovery_test.go new file mode 100644 index 0000000000..8b266405f5 --- /dev/null +++ b/internal/session/task_remote_recovery_test.go @@ -0,0 +1,697 @@ +package session + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/plandb" + "os" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "testing" + "time" +) + +type taskTestClock struct { + mu sync.Mutex + now time.Time + waits []taskTestWait +} +type taskTestWait struct { + at time.Time + ch chan time.Time +} + +func newTaskTestClock() *taskTestClock { return &taskTestClock{now: time.Unix(100, 0)} } +func (c *taskTestClock) Now() time.Time { c.mu.Lock(); defer c.mu.Unlock(); return c.now } +func (c *taskTestClock) After(d time.Duration) <-chan time.Time { + c.mu.Lock() + defer c.mu.Unlock() + ch := make(chan time.Time, 1) + c.waits = append(c.waits, taskTestWait{c.now.Add(d), ch}) + return ch +} +func (c *taskTestClock) advance(d time.Duration) { + c.mu.Lock() + defer c.mu.Unlock() + c.now = c.now.Add(d) + var kept []taskTestWait + for _, w := range c.waits { + if !w.at.After(c.now) { + w.ch <- c.now + } else { + kept = append(kept, w) + } + } + c.waits = kept +} + +// Contract 9 names an unanswered computer within sixty injected seconds, +// even while a watch dial itself is still waiting to return. +func TestAComputerThatStopsAnsweringIsSaidWithinAMinute(t *testing.T) { + clock := newTaskTestClock() + watching := make(chan struct{}) + missing := make(chan bool, 2) + remote := &scriptedTaskEngine{start: func(ctx context.Context, spec RunSpec) RunSummary { + r := remoteRecordFor(spec) + _ = spec.SaveRemote(r, true) + err := FollowTaskWatch(ctx, clock, 0, func(ctx context.Context, from uint64, alive func(), deliver func(uint64) error) error { + close(watching) + <-ctx.Done() + return ctx.Err() + }, func(uint64) error { return nil }, func(no bool) { + r.Unreachable = no + _ = spec.SaveRemote(r, true) + spec.OnRemotePhase(TaskComputerStoppedAnswering("work computer")) + missing <- no + }) + return RunSummary{Outcome: "interrupted", Result: err.Error(), KeepCopy: true} + }} + a, _ := remoteTaskAgent(t, remote, nil) + if _, _, _, err := a.StartTaskOn(context.Background(), "Repair export", false, "work computer"); err != nil { + t.Fatal(err) + } + <-watching + // Each turn yields to the watch driver after waking its injected timer. + for i := 0; i < 60; i++ { + clock.advance(time.Second) + time.Sleep(time.Millisecond) + } + select { + case no := <-missing: + if !no { + t.Fatal("computer still shown reachable") + } + case <-time.After(time.Second): + t.Fatal("missing notice after sixty seconds") + } + notice, ok := runRowOf(a.graph(), 1) + if !ok || !notice.Unreachable || notice.Doing != "work computer stopped answering — it may still be working" { + t.Fatalf("task notice = %+v", notice) + } + _ = a.Close() + waitRemoteRun(t, a) +} + +// Contract 9 replays a cursor after a drop and never repeats an observed step. +func TestReconnectingReplaysMissedStepsWithoutDoubles(t *testing.T) { + remote := &scriptedTaskEngine{start: func(ctx context.Context, spec RunSpec) RunSummary { + r := remoteRecordFor(spec) + _ = spec.SaveRemote(r, true) + var seq uint64 + tries := 0 + watch := func(ctx context.Context, from uint64, alive func(), deliver func(uint64) error) error { + tries++ + alive() + frames := []uint64{1} + if tries == 2 { + if from != 1 { + t.Errorf("reconnected cursor = %d", from) + } + frames = []uint64{1, 2, 3} + } + for _, next := range frames { + seq = next + if err := deliver(next); err != nil { + return err + } + } + if tries == 1 { + return errors.New("network dropped") + } + return ErrTaskWatchEnded + } + err := FollowTaskWatch(ctx, nil, 0, watch, func(n uint64) error { + return spec.OnRemoteStep(n, []byte(fmt.Sprintf(`{"kind":"step","step":%d,"command":"step %d","observation":"bounded head","full_output":"/other/home/output"}`, seq, seq))) + }, func(bool) {}) + if err != nil { + t.Error(err) + } + _ = spec.Store.CompleteRoot("done") + return RunSummary{Outcome: "done"} + }} + a, _ := remoteTaskAgent(t, remote, nil) + if _, _, _, err := a.StartTaskOn(context.Background(), "Repair export", false, "work computer"); err != nil { + t.Fatal(err) + } + waitRemoteRun(t, a) + page, ok := a.PlanTaskPage("t-1") + if !ok { + t.Fatal("task page missing") + } + if len(page.Steps) != 3 { + t.Fatalf("task steps = %+v", page.Steps) + } + for i, step := range page.Steps { + if step.Command != fmt.Sprintf("step %d", i+1) || step.FullOutput != "" { + t.Fatalf("step %d = %+v", i, step) + } + } +} + +// Contract 10 closes and reopens the actual conversation record; recovery +// watches the saved attempt/cursor and cannot issue another remote start. +func TestAReopenedConversationReattachesWithoutStartingAgain(t *testing.T) { + t.Setenv("CODEAF_TASK_BELT", "bash") + local := newBeltRunDouble("done") + local.real = true + registerBeltRunEngine(t, local) + var starts, resumes atomic.Int32 + ready, resumed := make(chan struct{}), make(chan RunSpec, 1) + finish := make(chan struct{}) + remote := &scriptedTaskEngine{start: func(ctx context.Context, spec RunSpec) RunSummary { + starts.Add(1) + r := remoteRecordFor(spec) + r.Seq = 7 + _ = spec.SaveRemote(r, true) + close(ready) + <-ctx.Done() + return RunSummary{Outcome: "interrupted", KeepCopy: true} + }, resume: func(ctx context.Context, spec RunSpec) RunSummary { + resumes.Add(1) + resumed <- spec + <-finish + r := *spec.Remote + r.Finished = true + _ = spec.SaveRemote(r, true) + _ = spec.Store.CompleteRoot("done elsewhere") + return RunSummary{Outcome: "done", Result: "done elsewhere"} + }} + place, repo, profile := t.TempDir(), beltRunCommittedRepo(t), t.TempDir() + open := func() *Agent { + a, _ := newTestAgent(t, beltRunCompleter{text: "done"}, func(c *Config) { + c.Workspace = repo + c.Place = Place{Dir: place} + c.SessionFile = c.Place.Transcript() + c.ProfileDir = profile + c.TaskComputers = testTaskComputers{engine: remote} + }) + return a + } + first := open() + if _, _, _, err := first.StartTaskOn(context.Background(), "Repair export", false, "work computer"); err != nil { + t.Fatal(err) + } + <-ready + _ = first.Close() + waitRemoteRun(t, first) + second := open() + select { + case spec := <-resumed: + if spec.Remote.Seq != 7 || spec.Remote.Attempt != "att_0123456789abcdef" { + t.Fatalf("resume record = %+v", spec.Remote) + } + case <-time.After(5 * time.Second): + t.Fatal("reopen did not reattach") + } + if starts.Load() != 1 || resumes.Load() != 1 || local.didRun() { + t.Fatal("reopen started another worker") + } + close(finish) + waitRemoteRun(t, second) + if local.lands() != 1 { + t.Fatalf("landings = %d", local.lands()) + } +} + +// Contracts 8 and 11 fence a dropped attempt before its local replacement. +// Even a result callback arriving after that fence cannot touch the copy. +func TestRunItHereRunsLocallyAndAnAbandonedResultNeverLands(t *testing.T) { + ready := make(chan struct{}) + late := make(chan error, 1) + abandoned := make(chan struct{}, 1) + remote := &scriptedTaskEngine{start: func(ctx context.Context, spec RunSpec) RunSummary { + r := remoteRecordFor(spec) + r.Unreachable = true + _ = spec.SaveRemote(r, true) + close(ready) + <-ctx.Done() + late <- spec.WithRemoteCopy(func() error { + return os.WriteFile(filepath.Join(spec.Workspace, "late.txt"), []byte("late remote result"), 0600) + }) + return RunSummary{Outcome: "done", Result: "late result"} + }, abandon: func() { abandoned <- struct{}{} }} + a, local := remoteTaskAgent(t, remote, nil) + if _, _, _, err := a.StartTaskOn(context.Background(), "Repair export", false, "work computer"); err != nil { + t.Fatal(err) + } + <-ready + receipt, err := a.RunTaskHere(context.Background(), 1) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(receipt, "here") { + t.Fatalf("receipt = %s", receipt) + } + select { + case <-local.entered: + case <-time.After(5 * time.Second): + t.Fatal("local replacement never started") + } + if err := <-late; !errors.Is(err, ErrTaskAttemptAbandoned) { + t.Fatalf("late result = %v", err) + } + <-abandoned + endBeltRun(t, a, local) + if _, err := os.Stat(filepath.Join(a.config.Workspace, "late.txt")); !os.IsNotExist(err) { + t.Fatal("abandoned result landed") + } + kept, _ := runRowOf(a.graph(), 1) + if kept.Computer != "" || kept.Unreachable { + t.Fatalf("local placement = %+v", kept) + } + if local.lands() != 1 { + t.Fatalf("landing calls = %d", local.lands()) + } +} + +// Contract 8 preserves a refused remote start's ready copy until the person +// explicitly chooses here; that choice starts locally in the same copy. +func TestARefusedRemoteStartWaitsForRunItHere(t *testing.T) { + var sent string + remote := &scriptedTaskEngine{start: func(ctx context.Context, spec RunSpec) RunSummary { + sent = spec.Workspace + r := remoteRecordFor(spec) + r.StartFailed = true + _ = spec.SaveRemote(r, true) + return RunSummary{Outcome: "incomplete", Result: "work computer is not taking tasks right now — run codeaf serve on it", KeepCopy: true} + }} + a, local := remoteTaskAgent(t, remote, nil) + if _, _, _, err := a.StartTaskOn(context.Background(), "Repair export", false, "work computer"); err != nil { + t.Fatal(err) + } + waitRemoteRun(t, a) + if local.didRun() { + t.Fatal("refusal fell back without a person's choice") + } + if _, err := os.Stat(sent); err != nil { + t.Fatal("refusal removed the ready copy") + } + if _, err := a.RunTaskHere(context.Background(), 1); err != nil { + t.Fatal(err) + } + <-local.entered + local.mu.Lock() + got := local.spec.Workspace + local.mu.Unlock() + if got != sent { + t.Fatalf("replacement copy = %s, sent %s", got, sent) + } + endBeltRun(t, a, local) +} + +// Contract 11 refuses local replacement before abandoning anything if the +// copy here changed while the other computer was working. +func TestRunItHereKeepsAChangedCopyAndTheRemoteAttempt(t *testing.T) { + ready := make(chan struct{}) + var abandons atomic.Int32 + remote := &scriptedTaskEngine{start: func(ctx context.Context, spec RunSpec) RunSummary { + r := remoteRecordFor(spec) + r.Unreachable = true + _ = spec.SaveRemote(r, true) + close(ready) + <-ctx.Done() + return RunSummary{Outcome: "interrupted", KeepCopy: true} + }, check: func(context.Context, string, string) error { + return errors.New("the task copy changed here while the other computer worked; both copies were kept") + }, abandon: func() { abandons.Add(1) }} + a, local := remoteTaskAgent(t, remote, nil) + if _, _, _, err := a.StartTaskOn(context.Background(), "Repair export", false, "work computer"); err != nil { + t.Fatal(err) + } + <-ready + if _, err := a.RunTaskHere(context.Background(), 1); err == nil { + t.Fatal("changed copy allowed replacement") + } + kept, _ := runRowOf(a.graph(), 1) + if kept.Remote.Abandoned || abandons.Load() != 0 || local.didRun() { + t.Fatal("refused replacement changed the attempt") + } + _ = a.Close() + waitRemoteRun(t, a) +} + +// Contract 5 includes a crash after both accounting writes but before the +// throttled cursor checkpoint. The replayed call is still one conversation call. +func TestRemoteChargeReplayAfterReopenDoesNotChargeTheConversationTwice(t *testing.T) { + t.Setenv("CODEAF_TASK_BELT", "bash") + local := newBeltRunDouble("done") + local.real = true + registerBeltRunEngine(t, local) + charged := make(chan struct{}) + var resumed atomic.Int32 + charge := RunCharge{Model: "remote/model", TokensIn: 40, TokensOut: 8, USD: .25} + remote := &scriptedTaskEngine{start: func(ctx context.Context, spec RunSpec) RunSummary { + r := remoteRecordFor(spec) + _ = spec.SaveRemote(r, true) + if err := spec.OnRemoteCharge(1, charge, "worker"); err != nil { + t.Error(err) + } + close(charged) + <-ctx.Done() + return RunSummary{Outcome: "interrupted", KeepCopy: true} + }, resume: func(ctx context.Context, spec RunSpec) RunSummary { + resumed.Add(1) + if spec.Remote.Seq != 0 { + t.Errorf("fixture cursor = %d", spec.Remote.Seq) + } + if err := spec.OnRemoteCharge(1, charge, "worker"); err != nil { + t.Error(err) + } + r := *spec.Remote + r.Seq = 1 + r.USD = .25 + r.Finished = true + _ = spec.SaveRemote(r, true) + _ = spec.Store.CompleteRoot("done") + return RunSummary{Outcome: "done", USD: .25} + }} + place, repo, profile := t.TempDir(), beltRunCommittedRepo(t), t.TempDir() + ledger := filepath.Join(profile, UsageLedgerName) + open := func() *Agent { + a, _ := newTestAgent(t, beltRunCompleter{text: "done"}, func(c *Config) { + c.Workspace = repo + c.Place = Place{Dir: place} + c.SessionFile = c.Place.Transcript() + c.ProfileDir = profile + c.TaskComputers = testTaskComputers{engine: remote} + c.usageLedger = ledger + }) + return a + } + first := open() + if _, _, _, err := first.StartTaskOn(context.Background(), "Repair export", false, "work computer"); err != nil { + t.Fatal(err) + } + <-charged + _ = first.Close() + waitRemoteRun(t, first) + second := open() + waitRemoteRun(t, second) + if resumed.Load() != 1 { + t.Fatal("task did not replay the saved attempt") + } + if usage := second.Usage(); usage.CostUSD != .25 || usage.Calls != 1 { + t.Fatalf("replayed usage = %+v", usage) + } + lines, err := ReadUsage(ledger, time.Time{}) + if err != nil || len(lines) != 1 { + t.Fatalf("ledger = %+v, %v", lines, err) + } +} + +// Contract 2's durable step mirror does linear work in the record size. Replay +// reads the existing prefix once, and never writes an already held event again. +func TestRemoteStepMirroringReadsOnceAndWritesEachNewStepOnce(t *testing.T) { + path := filepath.Join(t.TempDir(), "plandb.db") + store, err := plandb.Open(path, "task", "1", "task", "brief") + if err != nil { + t.Fatal(err) + } + defer store.Close() + mirror := &remoteStepMirror{} + for i := 1; i <= 200; i++ { + raw := []byte(fmt.Sprintf(`{"kind":"step","step":%d,"command":"inspect","observation":"head"}`, i)) + if err := mirror.append(store, "att_0123456789abcdef", uint64(i), raw); err != nil { + t.Fatal(err) + } + } + if mirror.readBytes != 0 { + t.Fatalf("new record read %d bytes", mirror.readBytes) + } + before := mirror.writeBytes + replay := &remoteStepMirror{} + for i := 1; i <= 200; i++ { + if err := replay.append(store, "att_0123456789abcdef", uint64(i), []byte(`{"kind":"step"}`)); err != nil { + t.Fatal(err) + } + } + if replay.readBytes != before || replay.writeBytes != 0 { + t.Fatalf("replay read/write = %d/%d, original %d", replay.readBytes, replay.writeBytes, before) + } + t.Logf("200 steps: %d bytes written, replay read %d bytes once and wrote %d", before, replay.readBytes, replay.writeBytes) +} + +// Contract 6 keeps local placement usable while a paired directory is offline. +func TestChoosingHereDoesNotNeedTheOtherComputerOrTheDirectory(t *testing.T) { + a, local := remoteTaskAgent(t, nil, nil) + a.config.TaskComputers = offlineTaskComputers{} + if _, _, _, err := a.StartTaskOn(context.Background(), "Repair export", false, "here"); err != nil { + t.Fatal(err) + } + <-local.entered + endBeltRun(t, a, local) +} + +type offlineTaskComputers struct{ testTaskComputers } + +func (offlineTaskComputers) List(context.Context) ([]TaskComputer, error) { + return nil, errors.New("directory is offline") +} + +// Contract 5 includes another owner appending after a long-lived process has +// already cached the ledger prefix. Its replay refreshes that tail first. +func TestRemoteSpendReplaySeesAnotherOwnersAppendedCharge(t *testing.T) { + path := filepath.Join(t.TempDir(), UsageLedgerName) + now := time.Now() + first := UsageLine{At: now, Day: now.Local().Format(usageDayLayout), RemoteEvent: "att_0123456789abcdef:1", Model: "remote/model", USD: .10, Calls: 1} + if err := appendRemoteUsage(path, first); err != nil { + t.Fatal(err) + } + later := UsageLine{At: now, Day: now.Local().Format(usageDayLayout), RemoteEvent: "att_0123456789abcdef:2", Model: "remote/model", USD: .20, Calls: 1} + raw, _ := json.Marshal(later) + file, err := os.OpenFile(path, os.O_WRONLY|os.O_APPEND, 0600) + if err != nil { + t.Fatal(err) + } + _, err = file.Write(append(raw, '\n')) + _ = file.Close() + if err != nil { + t.Fatal(err) + } + if err := appendRemoteUsage(path, later); err != nil { + t.Fatal(err) + } + lines, err := ReadUsage(path, time.Time{}) + if err != nil || len(lines) != 2 { + t.Fatalf("replayed ledger = %+v, %v", lines, err) + } +} + +// Contract 3 keeps a stopped task's partial change on its local task branch, +// and the person's Stop travels to the remote engine without cutting its watch. +func TestStoppingARemoteTaskReturnsItsPartialChangeAndKeepsItsBranch(t *testing.T) { + ready, stop := make(chan struct{}), make(chan struct{}) + remote := &scriptedTaskEngine{stop: func() { close(stop) }, start: func(ctx context.Context, spec RunSpec) RunSummary { + r := remoteRecordFor(spec) + _ = spec.SaveRemote(r, true) + close(ready) + select { + case <-stop: + case <-ctx.Done(): + t.Error("Stop cut the watch before its partial result came back") + return RunSummary{KeepCopy: true} + } + if err := spec.WithRemoteCopy(func() error { + return os.WriteFile(filepath.Join(spec.Workspace, "partial.txt"), []byte("partial work"), 0600) + }); err != nil { + t.Error(err) + } + r.Finished = true + _ = spec.SaveRemote(r, true) + return RunSummary{Outcome: "stopped", Result: "stopped with partial work", StorageSafe: true} + }} + a, local := remoteTaskAgent(t, remote, nil) + if _, _, _, err := a.StartTaskOn(context.Background(), "Repair export", false, "work computer"); err != nil { + t.Fatal(err) + } + <-ready + if _, err := a.Cancel("task:1"); err != nil { + t.Fatal(err) + } + waitRemoteRun(t, a) + kept, _ := runRowOf(a.graph(), 1) + if !kept.Stopped || kept.Branch == "" || ProjectTask(kept.StatusFacts()).Changes != TaskChangesKept { + t.Fatalf("stopped task = %+v", kept) + } + if _, err := os.Stat(filepath.Join(a.config.Workspace, "partial.txt")); !os.IsNotExist(err) { + t.Fatal("stopped work merged into the person's project") + } + if out, err := git(a.config.Workspace, "show", kept.Branch+":partial.txt"); err != nil || strings.TrimSpace(out) != "partial work" { + t.Fatalf("kept partial change = %q, %v", out, err) + } + if local.didRun() { + t.Fatal("Stop started a local worker") + } +} + +// Contract 8 applies to any saved refused task number, even after a later +// task has archived its plan. The replacement must receive that task's brief. +func TestRunItHereUsesTheRequestedTasksArchivedInstruction(t *testing.T) { + remote := &scriptedTaskEngine{start: func(ctx context.Context, spec RunSpec) RunSummary { + r := remoteRecordFor(spec) + r.StartFailed = true + _ = spec.SaveRemote(r, true) + return RunSummary{Outcome: "incomplete", KeepCopy: true} + }} + a, local := remoteTaskAgent(t, remote, nil) + if _, _, _, err := a.StartTaskOn(context.Background(), "Repair the export", false, "work computer"); err != nil { + t.Fatal(err) + } + waitRemoteRun(t, a) + if _, _, _, err := a.StartTask(context.Background(), "Repair the import", false); err != nil { + t.Fatal(err) + } + <-local.entered + endBeltRun(t, a, local) + replacement := newBeltRunDouble("done") + replacement.real = true + registerBeltRunEngine(t, replacement) + if _, err := a.RunTaskHere(context.Background(), 1); err != nil { + t.Fatal(err) + } + <-replacement.entered + replacement.mu.Lock() + brief := replacement.spec.Brief + replacement.mu.Unlock() + if brief != "Repair the export" { + t.Fatalf("replacement instruction = %q", brief) + } + endBeltRun(t, a, replacement) +} + +// Contract 9 replays a step whose append was interrupted before its newline. +func TestReplayingAStepWithAnUnfinishedAppendKeepsTheStep(t *testing.T) { + store, err := plandb.Open(filepath.Join(t.TempDir(), "plandb.db"), "task", "1", "task", "brief") + if err != nil { + t.Fatal(err) + } + defer store.Close() + path := filepath.Join(plandb.TaskDir(filepath.Dir(store.Path()), "1"), "trajectory.jsonl") + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + t.Fatal(err) + } + raw := []byte(`{"kind":"step","step":1,"command":"inspect","remote_attempt":"att_0123456789abcdef","remote_seq":1}`) + if err := os.WriteFile(path, raw, 0600); err != nil { + t.Fatal(err) + } + mirror := &remoteStepMirror{} + if err := mirror.append(store, "att_0123456789abcdef", 1, raw); err != nil { + t.Fatal(err) + } + if got := planTrajectory(filepath.Dir(store.Path()), "1"); len(got) != 1 || got[0].Command != "inspect" { + t.Fatalf("replayed trajectory = %+v", got) + } +} + +// Contracts 3 and 10 retain an outstanding remote stop across a quit. The +// reopened watch still collects the partial result before settling the stop. +func TestQuittingWhileARemoteTaskStopsReattachesForItsPartialResult(t *testing.T) { + t.Setenv("CODEAF_TASK_BELT", "bash") + local := newBeltRunDouble("done") + local.real = true + registerBeltRunEngine(t, local) + ready, stopping := make(chan struct{}), make(chan struct{}, 2) + var resumes atomic.Int32 + remote := &scriptedTaskEngine{stop: func() { stopping <- struct{}{} }, start: func(ctx context.Context, spec RunSpec) RunSummary { + r := remoteRecordFor(spec) + _ = spec.SaveRemote(r, true) + close(ready) + <-ctx.Done() + return RunSummary{Outcome: "interrupted", KeepCopy: true} + }, resume: func(ctx context.Context, spec RunSpec) RunSummary { + resumes.Add(1) + if err := spec.WithRemoteCopy(func() error { + return os.WriteFile(filepath.Join(spec.Workspace, "partial.txt"), []byte("stopped partial work"), 0600) + }); err != nil { + t.Error(err) + } + r := *spec.Remote + r.Finished = true + _ = spec.SaveRemote(r, true) + return RunSummary{Outcome: "stopped", StorageSafe: true} + }} + place, repo, profile := t.TempDir(), beltRunCommittedRepo(t), t.TempDir() + open := func() *Agent { + a, _ := newTestAgent(t, beltRunCompleter{text: "done"}, func(c *Config) { + c.Workspace = repo + c.Place = Place{Dir: place} + c.SessionFile = c.Place.Transcript() + c.ProfileDir = profile + c.TaskComputers = testTaskComputers{engine: remote} + }) + return a + } + first := open() + if _, _, _, err := first.StartTaskOn(context.Background(), "Repair export", false, "work computer"); err != nil { + t.Fatal(err) + } + <-ready + if _, err := first.Cancel("task:1"); err != nil { + t.Fatal(err) + } + <-stopping + _ = first.Close() + waitRemoteRun(t, first) + second := open() + waitRemoteRun(t, second) + if resumes.Load() != 1 { + t.Fatal("pending stop did not reattach") + } + kept, _ := runRowOf(second.graph(), 1) + if !kept.Stopped || kept.Branch == "" { + t.Fatalf("stopped partial work was not kept: %+v", kept) + } +} + +// Contract 4 keeps a single private repository copy eligible for the remote +// preference, then uses the ordinary local Review and Apply entry points. +func TestAPrivateTaskRunsElsewhereAndWaitsForLocalReviewAndApply(t *testing.T) { + t.Setenv("CODEAF_HOME", t.TempDir()) + t.Setenv("CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP", "1") + reached := make(chan struct{}, 1) + remote := &scriptedTaskEngine{start: func(ctx context.Context, spec RunSpec) RunSummary { + reached <- struct{}{} + r := remoteRecordFor(spec) + r.Finished, r.Restored = true, true + _ = spec.SaveRemote(r, true) + if err := os.WriteFile(filepath.Join(spec.Workspace, "private-output.txt"), []byte("the returned private change\n"), 0600); err != nil { + t.Error(err) + } + _ = spec.Store.CompleteRoot("private change made") + return RunSummary{Outcome: "done", StorageSafe: true} + }} + a, local := remoteTaskAgent(t, remote, func(c *Config) { _ = config.WriteTaskRunsOn(c.ProfileDir, "dev_0123456789abcdef0123456789abcdef") }) + id, _, _, err := a.StartTask(context.Background(), "write private-output.txt in this project", false) + if err != nil { + t.Fatal(err) + } + select { + case <-reached: + case <-local.entered: + t.Fatal("private task preference ran locally") + case <-time.After(5 * time.Second): + t.Fatal("private task never started") + } + waitRemoteRun(t, a) + if _, err := os.Stat(filepath.Join(a.config.Workspace, "private-output.txt")); !os.IsNotExist(err) { + t.Fatal("private result applied before review") + } + review, err := a.ReviewTaskWorkspaceRoot(context.Background(), id, "work") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(review.Diff, "the returned private change") { + t.Fatalf("review = %+v", review) + } + if _, err := a.ApplyTaskWorkspaceRoot(context.Background(), id, "work"); err != nil { + t.Fatal(err) + } + if got, err := os.ReadFile(filepath.Join(a.config.Workspace, "private-output.txt")); err != nil || string(got) != "the returned private change\n" { + t.Fatalf("applied result = %q, %v", got, err) + } +} diff --git a/internal/session/task_remote_watch.go b/internal/session/task_remote_watch.go new file mode 100644 index 0000000000..5b0fcbb8d6 --- /dev/null +++ b/internal/session/task_remote_watch.go @@ -0,0 +1,155 @@ +package session + +import ( + "context" + "errors" + "time" +) + +const ( + TaskComputerSilentAfter = time.Minute + TaskComputerRetryEvery = 30 * time.Second +) + +// TaskComputerStoppedAnswering is shared with every surface drawing a drop. +func TaskComputerStoppedAnswering(name string) string { + return name + " stopped answering — it may still be working" +} +func TaskRunningOn(name string) string { return "running on " + name } +func TaskRanOn(name string) string { return "ran on " + name } + +// TaskWatchClock supplies wakeups as well as readings so a network outage can +// be tested without spending a minute on the wall clock. +type TaskWatchClock interface { + Now() time.Time + After(time.Duration) <-chan time.Time +} +type taskWatchWall struct{} + +func (taskWatchWall) Now() time.Time { return time.Now() } +func (taskWatchWall) After(d time.Duration) <-chan time.Time { return time.After(d) } + +// TaskWatch retries only a watch. Activity includes pongs, so quiet useful work +// is never called missing. deliver waits until local writes have completed. +type TaskWatch func(context.Context, uint64, func(), func(uint64) error) error + +type taskWatchDelivery struct { + seq uint64 + done chan error +} + +// FollowTaskWatch serializes frames and reachability on its caller's goroutine. +// The network goroutine is joined on every exit and never sends a second start. +func FollowTaskWatch(ctx context.Context, clock TaskWatchClock, from uint64, watch TaskWatch, consume func(uint64) error, reachability func(bool)) error { + if clock == nil { + clock = taskWatchWall{} + } + last := clock.Now() + delay := time.Second + unreachable := false + notice := func() { + if !unreachable && clock.Now().Sub(last) >= TaskComputerSilentAfter { + unreachable = true + reachability(true) + } + } + for { + child, cancel := context.WithCancel(ctx) + done := make(chan error, 1) + activity := make(chan struct{}, 1) + deliveries := make(chan taskWatchDelivery) + alive := func() { + select { + case activity <- struct{}{}: + default: + } + } + go func(cursor uint64) { + done <- watch(child, cursor, alive, func(seq uint64) error { + delivery := taskWatchDelivery{seq: seq, done: make(chan error, 1)} + select { + case deliveries <- delivery: + case <-child.Done(): + return child.Err() + } + select { + case err := <-delivery.done: + return err + case <-child.Done(): + return child.Err() + } + }) + }(from) + tick := clock.After(time.Second) + var err error + watching: + for { + select { + case <-activity: + last = clock.Now() + delay = time.Second + if unreachable { + unreachable = false + reachability(false) + } + case delivery := <-deliveries: + var consumeErr error + if delivery.seq > from { + consumeErr = consume(delivery.seq) + if consumeErr == nil { + from = delivery.seq + } + } + delivery.done <- consumeErr + if consumeErr != nil { + cancel() + <-done + return consumeErr + } + case err = <-done: + break watching + case <-ctx.Done(): + cancel() + <-done + return ctx.Err() + case <-tick: + notice() + tick = clock.After(time.Second) + } + } + cancel() + if err == nil || errors.Is(err, ErrTaskWatchEnded) { + return nil + } + if ctx.Err() != nil { + return ctx.Err() + } + wait := delay + if unreachable { + wait = TaskComputerRetryEvery + } + retry := clock.After(wait) + tick = clock.After(time.Second) + waiting: + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-retry: + break waiting + case <-tick: + notice() + tick = clock.After(time.Second) + } + } + if delay < TaskComputerRetryEvery { + delay *= 2 + if delay > TaskComputerRetryEvery { + delay = TaskComputerRetryEvery + } + } + } +} + +// ErrTaskWatchEnded distinguishes a final frame from a transport drop. +var ErrTaskWatchEnded = errors.New("the task watch ended") diff --git a/internal/session/task_run_admission.go b/internal/session/task_run_admission.go index c30b8c73f1..becdb43d2b 100644 --- a/internal/session/task_run_admission.go +++ b/internal/session/task_run_admission.go @@ -45,6 +45,11 @@ func (a *Agent) beltRunQuiet(run *beltRun) bool { return run.quiet } func (a *Agent) settleBeltStopIfNeeded(run *beltRun, summary RunSummary) bool { + // A remote stop still needs its partial result. A closed watch or failed + // transfer cannot retire the untouched local copy as though it came back. + if run.remoteEngine != nil && summary.KeepCopy { + return false + } if stopped, why := a.beltRunStopped(run); stopped { a.settleStoppedBeltRun(run, why, summary.Cut) a.settleTaskCrew(run.row, router.CrewStopped, summary.USD) diff --git a/internal/session/task_run_belt.go b/internal/session/task_run_belt.go index 14dea043f5..2f84a759a6 100644 --- a/internal/session/task_run_belt.go +++ b/internal/session/task_run_belt.go @@ -32,6 +32,7 @@ package session import ( "context" + "encoding/json" "errors" "fmt" "math" @@ -119,6 +120,18 @@ func (a *Agent) holdProgramCeilings(run *beltRun, spec RunSpec) { // the working copy its workers share, the run's own words, the conversation's // two limits, and the provider its workers are seated on. type RunSpec struct { + // Remote is the durable attempt being followed. SaveRemote fences abandoned + // attempts; WithRemoteCopy serializes result restore against running here. + Remote *TaskRemoteRecord + SaveRemote func(TaskRemoteRecord, bool) error + WithRemoteCopy func(func() error) error + OnRemoteStep func(uint64, json.RawMessage) error + OnRemoteCharge func(uint64, RunCharge, string) error + OnRemotePhase func(string) + RemoteStopped func() bool + TaskNumber uint64 + NamedModel, NamedEffort string + NoProgress int // Store is the plan the run drives. The door opened it and keeps it open // for the life of the run; the engine reads and writes it like any other // writer of the store. @@ -280,6 +293,11 @@ const ( // the engine's outcome word, the root's result, the run's size, and the limit // that ended it when one did. type RunSummary struct { + Model string + // KeepCopy preserves the untouched origin copy after a start refusal or a + // result conflict. NeedsDecision says both copies need the person's choice. + KeepCopy bool + NeedsDecision bool // StorageSafe means every worker closed its jobs and no owned process group // remained alive when the run returned. Missing proof keeps copies resident. StorageSafe bool @@ -358,13 +376,19 @@ var beltRunSummaryDeadline = 6 * time.Second // on the Agent and nowhere else, so ownership of a running run is this // process's. type beltRun struct { - plan *planState - store *plandb.Store - root string - row uint64 - title string - brief string - stand taskStand + remoteMu sync.Mutex + remote *TaskRemoteRecord + remoteEngine TaskRunEngine + remoteResume bool + remoteSaved time.Time + placement taskPlacement + plan *planState + store *plandb.Store + root string + row uint64 + title string + brief string + stand taskStand // pending is true until the run's machine admission has passed. A pending // run has a plan store but no folder or working copy, so stopping it cannot // leave a repository lock or branch behind. @@ -582,6 +606,24 @@ func (a *Agent) startOrJoinTaskRunVia(ctx context.Context, id uint64, title, bri return false, err } } + placement := taskPlacementOf(ctx) + if _, resolved := ctx.Value(taskPlacementKey{}).(taskPlacement); !resolved { + placement, err = a.resolveTaskComputer(ctx, "", stand, "", via != nil) + if err != nil { + return false, err + } + ctx = withTaskPlacement(ctx, placement) + } + if placement.computer != nil { + remote, remoteErr := a.config.TaskComputers.Engine(ctx, placement.computer.Device) + if remoteErr != nil { + return false, remoteErr + } + if remote == nil { + return false, errors.New("this computer cannot run that task elsewhere right now") + } + engine = remote + } path := g.planPath() storeID := strconv.FormatUint(id, 10) dependencies := make([]plandb.Dependency, 0, len(dependsOn)) @@ -712,6 +754,7 @@ func (a *Agent) startAdmittedBeltRun(ctx context.Context, engine RunEngine, g *T delegate: via, folder: folder, asked: asked, crew: crew, } run.wishedFrom(ctx) + a.placeBeltRun(ctx, engine, run) a.installBeltRun(g, run) // THE COPY IS WRITTEN DOWN IN THE SAME BREATH THE RUN IS PUBLISHED, because // the branch it names exists only in this variable until it is: the road that @@ -723,6 +766,7 @@ func (a *Agent) startAdmittedBeltRun(ctx context.Context, engine RunEngine, g *T // `via` — so every later publish carries it forward from here // ([TaskNotice.Program], [Agent.publishRunRow]). a.publishRunRow(g, TaskNotice{ + Remote: cloneTaskRemote(run.remote), Computer: run.taskComputerName(), ID: id, Title: title, State: TaskRunning, StartedAt: born, Copy: runCopyOf(tree), WorkspaceSet: cloneWorkspaceSet(tree.workspaceSet), Program: programName(via), // THE ROOT'S ROW NAMES THE STORE'S ROOT, which is this same number: the @@ -746,7 +790,7 @@ func (a *Agent) startAdmittedBeltRun(ctx context.Context, engine RunEngine, g *T func (a *Agent) startHeldBeltRun(ctx context.Context, engine RunEngine, g *TaskGraph, path, storeID string, id uint64, title, brief string, stand taskStand, question string, via *delegate.Delegate, asked []string, admission RunAdmission) (bool, error) { var crew *taskCrew var err error - if via == nil { + if via == nil && taskPlacementOf(ctx).computer == nil { crew, err = a.routeTaskCrew(ctx, id, title, brief) if err != nil { return false, err @@ -776,12 +820,13 @@ func (a *Agent) startHeldBeltRun(ctx context.Context, engine RunEngine, g *TaskG delegate: via, asked: asked, crew: crew, } run.wishedFrom(ctx) + a.placeBeltRun(ctx, engine, run) a.installBeltRun(g, run) // A HELD RUN IS QUEUED, not working: there is no worker and deliberately no // copy yet. Its plan id is present from the first publish, which gives stop // and recovery one persisted identity to reconcile. a.publishRunRow(g, TaskNotice{ - ID: id, Title: title, State: TaskQueued, Program: programName(via), + ID: id, Title: title, State: TaskQueued, Program: programName(via), Remote: cloneTaskRemote(run.remote), Computer: run.taskComputerName(), PlanTask: planStoreID(storeID), Crew: run.crewDecision(), CrewState: a.initialCrewRecord(run), Model: run.crewWorker(), Waiting: waitingMachineBusy, PendingRun: &PendingRunRecord{WorkspaceSet: stand.workspaceSet, Brief: brief, Ground: stand.dir, Mode: stand.mode, Asked: asked}, @@ -797,7 +842,7 @@ func (a *Agent) startHeldBeltRun(ctx context.Context, engine RunEngine, g *TaskG func (a *Agent) prepareBeltRunStart(ctx context.Context, id uint64, title, brief, sessionDir string, stand taskStand, via *delegate.Delegate) (*taskCrew, *ProgramFolder, error) { var crew *taskCrew var err error - if via == nil { + if via == nil && taskPlacementOf(ctx).computer == nil { crew, err = a.routeTaskCrew(ctx, id, title, brief) if err != nil { return nil, nil, err @@ -913,6 +958,12 @@ func (a *Agent) joinOrWait(ctx context.Context, stand taskStand, id uint64, titl } over := live.over if !live.ending && !live.closing && !live.stopped { + // Remote attempts have a frozen instruction; the wire has no add-work verb. + // Refuse any join involving one instead of losing a joined obligation. + if live.remoteEngine != nil || taskPlacementOf(ctx).computer != nil { + a.beltMu.Unlock() + return nil, errRemotePlacementBusy + } if refusal := programJoinRefusal(via, live); refusal != nil { a.beltMu.Unlock() return nil, refusal @@ -1688,6 +1739,14 @@ func (a *Agent) installBeltRun(g *TaskGraph, run *beltRun) { // the work ended, which is the moment a person goes looking for its page. func (a *Agent) publishRunRow(g *TaskGraph, notice TaskNotice) { notice = carryRunRow(g, notice) + if notice.Remote == nil { + if kept, ok := runRowOf(g, notice.ID); ok { + notice.Remote = cloneTaskRemote(kept.Remote) + notice.Computer = kept.Computer + notice.Unreachable = kept.Unreachable + notice.RanElsewhere = kept.RanElsewhere + } + } if crew := a.crews.get(notice.ID); crew != nil { crew.persistMu.Lock() defer crew.persistMu.Unlock() @@ -1774,6 +1833,7 @@ func keptRunProgram(g *TaskGraph, notice TaskNotice) string { // all. A crash writes nothing either way; that store is ended by the next // process to find it ([endOrphanedProgramRun]). func (a *Agent) cutBeltRun() <-chan struct{} { + a.remoteAckJobs.Wait() a.beltMu.Lock() run := a.beltRun var cut context.CancelFunc @@ -1951,9 +2011,29 @@ func (a *Agent) driveBeltRun(ctx context.Context, engine RunEngine, run *beltRun // and whatever the run's running total holds beyond those — a bash // worker's spend, which arrives only as a total. fold := &beltFold{agent: a} - spec.OnSpend = fold.total - spec.OnCharge = fold.charge - summary := startRunWithTaskStorageProof(ctx, engine, spec) + // Remote charges enter both origin books by event identity; a total reading + // must never fold them again through the local worker's accounting road. + if run.remoteEngine == nil { + spec.OnSpend = fold.total + spec.OnCharge = fold.charge + } + spec = a.remoteBeltSpec(run, spec) + var summary RunSummary + if run.remoteResume { + summary = run.remoteEngine.Resume(ctx, spec) + } else { + summary = startRunWithTaskStorageProof(ctx, engine, spec) + } + if run.remoteEngine != nil { + engine = chatRunEngine + } + run.remoteMu.Lock() + abandoned := run.remote != nil && run.remote.Abandoned + run.remoteMu.Unlock() + if abandoned { + a.releaseBeltRun(run) + return + } // THE RUN'S WORK IS OVER THE MOMENT THE ENGINE ANSWERS, and that instant is // taken now, before the landing, the summary refresh and the note — which // can take a quarter of a minute between them and are not the work. @@ -1965,14 +2045,24 @@ func (a *Agent) driveBeltRun(ctx context.Context, engine RunEngine, run *beltRun // what the deferred release says once. a.beltMu.Lock() run.ended, run.spent = a.taskClockNow(), summary.USD + if run.remoteEngine != nil && summary.Outcome == "stopped" { + run.stopped = true + } run.ending = true closing := run.closing a.beltMu.Unlock() defer a.releaseBeltRun(run) - defer a.parkBeltRunStorage(run, summary) + if !summary.KeepCopy && run.remoteEngine == nil { + defer a.parkBeltRunStorage(run, summary) + } + if run.remoteEngine != nil && !summary.KeepCopy { + defer a.ackRemoteRun(run) + } // The final receipt closes any gap between the last live reading and every // ending, before the person-stop road and the ordinary landing road split. - fold.total(summary.USD) + if run.remoteEngine == nil { + fold.total(summary.USD) + } // AND meta.json IS TOLD NOW, not at the next turn's seal. Home reads this // conversation's bill as the larger of its stamped books and its index rows // (tui3's homeFacts), which is exact only while the books on disk already @@ -1982,6 +2072,14 @@ func (a *Agent) driveBeltRun(ctx context.Context, engine RunEngine, run *beltRun if run.cut != nil { defer run.cut() } + if closing && run.remoteEngine != nil { + run.remoteMu.Lock() + finished := run.remote != nil && run.remote.Finished + run.remoteMu.Unlock() + if !finished { + return + } + } if a.settleBeltStopIfNeeded(run, summary) { return } @@ -2000,7 +2098,15 @@ func (a *Agent) driveBeltRun(ctx context.Context, engine RunEngine, run *beltRun // branch below — never merged into the person's. return } - landing := a.finishBeltRunLanding(ctx, engine, run, summary) + var landing RunLanding + if summary.KeepCopy { + // Refusing a transfer leaves the copy ready, not the plan still running. + // Closing only the root records the ending without landing any files. + _ = run.store.EndRoot(summary.Outcome) + landing = RunLanding{Home: mergeKept, Branch: run.tree.branch, Refused: summary.Result} + } else { + landing = a.finishBeltRunLanding(ctx, engine, run, summary) + } if a.settleBeltStopIfNeeded(run, summary) { return } @@ -2010,7 +2116,9 @@ func (a *Agent) driveBeltRun(ctx context.Context, engine RunEngine, run *beltRun // beyond that bound. RefreshRunSummary itself declines without a store. if !a.beltRunQuiet(run) { refreshCtx, cancelRefresh := context.WithTimeout(withCrewTask(ctx, run.crew), beltRunSummaryDeadline) - a.RefreshRunSummary(refreshCtx, run.root, time.Time{}) + if run.remoteEngine == nil { + a.RefreshRunSummary(refreshCtx, run.root, time.Time{}) + } cancelRefresh() } note := beltRunOutcomeNote(run.store, run.root, summary, landing, a.beltRunSpan(run)) @@ -2497,6 +2605,12 @@ func (a *Agent) beltRunNotice(run *beltRun, summary RunSummary, landing RunLandi } report += line } + if summary.NeedsDecision { + state = TaskUnverified + } + if summary.Outcome == "interrupted" { + state = TaskInterrupted + } notice := TaskNotice{ ID: run.row, Title: run.title, State: state, // A LIMIT ITS PERSON SET IS THE ROW'S ENDING, so the reason a surface @@ -2511,11 +2625,23 @@ func (a *Agent) beltRunNotice(run *beltRun, summary RunSummary, landing RunLandi // picked under, for the card's crew line. Crew: run.crewDecision(), Model: run.crewWorker(), CostUSD: run.crew.taskSpent(summary.USD), } + if run.remoteEngine != nil { + if summary.Outcome == "stopped" { + notice.Stopped = true + notice.Ending = TaskEndingStopped + } + run.remoteMu.Lock() + notice.Remote = cloneTaskRemote(run.remote) + notice.Computer = run.remote.Executor.Name + notice.RanElsewhere = !run.remote.StartFailed + run.remoteMu.Unlock() + notice.Model = summary.Model + } if state == TaskFailed && run.crew != nil { decision := run.crew.stoppedIfCutOff() notice.Crew = &decision } - if run.crew != nil && !run.crew.anyStarted() { + if run.remoteEngine == nil && run.crew != nil && !run.crew.anyStarted() { // A CREW NO SEAT OF WHICH EVER ANSWERED WROTE NOTHING: whatever the // landing counted is the run's own scaffolding, and "1 file" under a // run that never started claims work nobody did. @@ -2692,6 +2818,11 @@ func (run *beltRun) crewWorker() string { // initialCrewRecord records the accepted seats even when routing is disabled. func (a *Agent) initialCrewRecord(run *beltRun) *TaskCrewRecord { + // B seats its own crew. Recording A's ladder would claim models that never + // worked and could spend on an origin-side route the target cannot use. + if run.remoteEngine != nil { + return &TaskCrewRecord{Version: 1, Work: run.placement.model} + } if run.crew != nil { return run.crew.record() } diff --git a/internal/session/task_run_continue.go b/internal/session/task_run_continue.go index 189e2da648..3ae66d285b 100644 --- a/internal/session/task_run_continue.go +++ b/internal/session/task_run_continue.go @@ -50,6 +50,9 @@ func (a *Agent) ContinueRun(ctx context.Context, row uint64) (string, error) { return "", errRunRoadUnavailable } + if kept, found := runRowOf(g, row); found && kept.Remote != nil && !kept.Remote.Abandoned { + return a.continueRemoteRun(ctx, kept) + } // A RUN ALREADY GOING IS NOT CARRIED ON. Read under the start lock, held // until the carried-on run is registered, because a run installed between // this look and the work below would be a second run on one conversation's diff --git a/internal/session/task_run_index.go b/internal/session/task_run_index.go index 376c30bd2c..f8e323a595 100644 --- a/internal/session/task_run_index.go +++ b/internal/session/task_run_index.go @@ -68,6 +68,7 @@ func (a *Agent) indexRunRow(notice TaskNotice) { // machine is a lie anywhere else. func crewRunEntry(notice TaskNotice, session string) TaskIndexEntry { entry := TaskIndexEntry{ + Computer: notice.Computer, Unreachable: notice.Unreachable, RanElsewhere: notice.RanElsewhere, ID: strconv.FormatUint(notice.ID, 10), Parent: taskIndexParent(notice.Parent), Name: TaskSlug(notice.Title), diff --git a/internal/session/task_run_recover.go b/internal/session/task_run_recover.go index c507a6d86a..f99b496305 100644 --- a/internal/session/task_run_recover.go +++ b/internal/session/task_run_recover.go @@ -23,6 +23,7 @@ func (a *Agent) recoverBeltRun() { if g == nil || g.planPath() == "" { return } + defer a.retrySavedRemoteAcks(g) if _, err := os.Stat(g.planPath()); err != nil { return } @@ -38,6 +39,41 @@ func (a *Agent) recoverBeltRun() { return } kept, found := runRowOf(g, id) + if found { + remote := readRemoteRecord(store, kept.Remote) + if remote != nil { + kept.Remote = remote + + if remote.Settled && !remote.Abandoned { + return + } + if !remote.Abandoned { + if kept.PendingRun != nil && remote.Attempt == "" { + a.resumePendingRun(g, kept) + return + } + if remote.StartFailed { + kept.State = TaskFailed + a.publishRunRow(g, kept) + return + } + if kept.State.settled() && kept.State != TaskInterrupted && !kept.EndedAt.IsZero() { + // A persisted final row already completed landing; only its ack can + // remain after a crash between settlement and acknowledgement. + if remote.Finished && remote.Restored { + remote.Settled = true + remote.AckPending = true + _ = writeRemoteRecord(store, *remote) + kept.Remote = remote + + } + return + } + _, _ = a.continueRemoteRun(a.rootContext(), kept) + return + } + } + } if !found || kept.State != TaskInterrupted { return } @@ -171,10 +207,24 @@ func (a *Agent) resumePendingRun(g *TaskGraph, kept TaskNotice) { admission: newRunAdmission(a.config.TaskMaxLoad, a.config.TaskMinFreeMB, a.config.ProfileDir, g.lanes), machineHeld: map[string]bool{planStoreID(reopened.RootID()): true}, } + engine := chatRunEngine + if kept.Remote != nil && !kept.Remote.Abandoned && a.config.TaskComputers != nil { + remote, err := a.taskExecutorEngine(ctx, kept.Remote.Executor) + if err != nil { + cut() + reopened.Close() + a.interruptUnrecoverableRun(g, kept, err.Error()) + return + } + engine = remote + run.remoteEngine = remote + run.remote = cloneTaskRemote(kept.Remote) + run.placement.computer = &TaskComputer{Device: kept.Remote.Executor.Device, Name: kept.Remote.Executor.Name, CanTakeTasks: true} + } a.installBeltRun(g, run) kept.State, kept.Waiting = TaskQueued, waitingMachineBusy a.publishRunRow(g, kept) - go a.driveBeltRun(ctx, chatRunEngine, run, RunSpec{}) + go a.driveBeltRun(ctx, engine, run, RunSpec{}) } // invalidPendingRun refuses incomplete or obsolete records instead of guessing diff --git a/internal/session/task_store.go b/internal/session/task_store.go index 96da8b2da9..700809a063 100644 --- a/internal/session/task_store.go +++ b/internal/session/task_store.go @@ -728,8 +728,12 @@ type harnessOfferRecord struct { // exists, and no answer could reach one. Left out, the row comes back settled // like every other row that was moving, which is the truth about it. type runRecord struct { - RecoveredCompletion bool `json:"recoveredCompletion,omitempty"` - CrewState *TaskCrewRecord `json:"crewState,omitempty"` + Remote *TaskRemoteRecord `json:"remote,omitempty"` + Computer string `json:"computer,omitempty"` + Unreachable bool `json:"unreachable,omitempty"` + RanElsewhere bool `json:"ran_elsewhere,omitempty"` + RecoveredCompletion bool `json:"recoveredCompletion,omitempty"` + CrewState *TaskCrewRecord `json:"crewState,omitempty"` ID uint64 `json:"id"` Run string `json:"run,omitempty"` @@ -1130,6 +1134,7 @@ func runRowRecord(notice TaskNotice) runRecord { state = TaskRunning } return runRecord{ + Remote: cloneTaskRemote(notice.Remote), Computer: notice.Computer, Unreachable: notice.Unreachable, RanElsewhere: notice.RanElsewhere, CrewState: notice.CrewState, ID: notice.ID, Run: notice.Run, @@ -1178,6 +1183,7 @@ func runRowRecord(notice TaskNotice) runRecord { // That is a fact about this reader and not a statement that the work is over. func runRowNotice(record runRecord) TaskNotice { notice := TaskNotice{ + Remote: cloneTaskRemote(record.Remote), Computer: record.Computer, Unreachable: record.Unreachable, RanElsewhere: record.RanElsewhere, CrewState: record.CrewState, ID: record.ID, Run: record.Run, diff --git a/internal/session/usage_ledger.go b/internal/session/usage_ledger.go index 5e78fbdee9..1148ce4e97 100644 --- a/internal/session/usage_ledger.go +++ b/internal/session/usage_ledger.go @@ -133,6 +133,10 @@ func UsageLedgerPath() string { return home.Join("v3", UsageLedgerName) } // how many tokens, how much money, and the three ids that say what the money was // spent ON — a conversation, a piece of work, an automation. type UsageLine struct { + // Computer and RemoteEvent identify a mirrored call independently of cursor + // checkpoints, so a replay cannot charge the same call twice. + Computer string `json:"computer,omitempty"` + RemoteEvent string `json:"remote_event,omitempty"` // At is the instant the call was journaled, RFC3339 with nanoseconds. At time.Time `json:"at"` // Day is At's LOCAL calendar day, "2006-01-02". It is written down rather @@ -942,10 +946,10 @@ func scanUsage(reader io.Reader, since time.Time) ([]UsageLine, int64, error) { // now happens on a writer goroutine ([RecordUsage]), the lock is still released // before the hand-off so no reader of the session's totals ever queues behind // the ledger at all. -func (a *Agent) recordUsageLine(call bankedCall) { +func (a *Agent) recordUsageLine(call bankedCall) error { used := call.used if used.Input == 0 && used.Output == 0 && used.CostUSD == 0 { - return + return nil } path := strings.TrimSpace(a.config.usageLedger) if path == "" { @@ -994,6 +998,11 @@ func (a *Agent) recordUsageLine(call bankedCall) { seat, _ = SeatOfAgent(a.agentKind()) } line = TagUsage(line, roles.Role(call.role), seat) + if remote := call.remote; remote != nil { + line.Task, line.Computer, line.RemoteEvent = remote.Task, remote.Computer, remote.Event + line.Seat = remote.Seat + return appendRemoteUsage(path, line) + } // THE LANE HALF OF THE LINE GOES ON THROUGH ONE DOOR, and it comes in as an // argument rather than being read from anywhere here — because the only @@ -1018,6 +1027,7 @@ func (a *Agent) recordUsageLine(call bankedCall) { if a.config.mayBashBelt() { a.recordPlanSpend(used, call.model) } + return nil } // usageTaskID spells a node's id the way the task index spells it, and answers From 91e81a63960db1a7231281e779c7d39d98f355fe Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Fri, 30 Oct 2026 18:33:33 -0400 Subject: [PATCH 06/34] taskplace: follow remote tasks and bring their changes home The adapter session reaches the task door through: it lists this identity's paired, non-revoked computers by their shown names, dials the chosen one, and streams its attempt's events back, recovering an admitted attempt from its saved executor without asking the directory first so an offline reopen can still reattach or run the copy here. Co-Authored-By: Claude Opus 5.5 --- cmd/codeaf/do_task_store.go | 4 + internal/taskdoor/copy.go | 15 ++ internal/taskdoor/dial.go | 4 + internal/taskdoor/executor.go | 5 + internal/taskplace/computers.go | 89 ++++++++ internal/taskplace/engine.go | 350 ++++++++++++++++++++++++++++++ internal/taskplace/engine_test.go | 257 ++++++++++++++++++++++ 7 files changed, 724 insertions(+) create mode 100644 internal/taskplace/computers.go create mode 100644 internal/taskplace/engine.go create mode 100644 internal/taskplace/engine_test.go diff --git a/cmd/codeaf/do_task_store.go b/cmd/codeaf/do_task_store.go index f101dd547d..84af088414 100644 --- a/cmd/codeaf/do_task_store.go +++ b/cmd/codeaf/do_task_store.go @@ -7,6 +7,7 @@ import ( "path/filepath" "time" + "github.com/Agent-Field/codeaf/internal/crewroute" "github.com/Agent-Field/codeaf/internal/effort" "github.com/Agent-Field/codeaf/internal/taskdoor" ) @@ -76,6 +77,9 @@ func doTaskControls(record string) (taskdoor.Start, effort.Rung, error) { if err := json.Unmarshal(raw, &request); err != nil { return request, effort.None, err } + if choice, ok := crewroute.ParseEffort(request.Effort); ok && choice != crewroute.EffortKnee { + return request, effort.None, nil + } rung, ok := effort.Parse(request.Effort) if !ok { return request, effort.None, errors.New("the task's reasoning effort is not readable") diff --git a/internal/taskdoor/copy.go b/internal/taskdoor/copy.go index 191f164d52..46d55eeeb4 100644 --- a/internal/taskdoor/copy.go +++ b/internal/taskdoor/copy.go @@ -385,3 +385,18 @@ func copyFingerprint(ctx context.Context, root string) (string, error) { _, _ = hash.Write([]byte(digest)) return hex.EncodeToString(hash.Sum(nil)), ctx.Err() } + +// CheckCopy fences a person's local replacement before any remote abandonment +// is recorded or stop is sent. It uses the same fingerprint as result restore. +func CheckCopy(ctx context.Context, path, fingerprint string) error { + if fingerprint == "" { + return errors.New("the sent task copy's fingerprint is missing") + } + return unchangedCopy(ctx, path, fingerprint) +} + +// CopyFingerprint lets a failed transfer retain a local replacement check +// even when no input head reached the other computer. +func CopyFingerprint(ctx context.Context, path string) (string, error) { + return copyFingerprint(ctx, path) +} diff --git a/internal/taskdoor/dial.go b/internal/taskdoor/dial.go index 5eb59c0268..cef26cf00b 100644 --- a/internal/taskdoor/dial.go +++ b/internal/taskdoor/dial.go @@ -210,3 +210,7 @@ func (c *Conn) Send(attempt string, payload any) error { } return c.sendFrame(frame) } + +// LastActivity includes keepalive replies, so the origin distinguishes a quiet +// worker from a computer whose connection stopped answering. +func (c *Conn) LastActivity() time.Time { return time.Unix(0, c.lastFrame.Load()) } diff --git a/internal/taskdoor/executor.go b/internal/taskdoor/executor.go index 3409f8121b..53da8c65aa 100644 --- a/internal/taskdoor/executor.go +++ b/internal/taskdoor/executor.go @@ -16,6 +16,7 @@ import ( "sync" "time" + "github.com/Agent-Field/codeaf/internal/crewroute" "github.com/Agent-Field/codeaf/internal/home" "github.com/Agent-Field/codeaf/internal/run" ) @@ -98,6 +99,10 @@ func (e *ChildExecutor) Run(ctx context.Context, a *Attempt, j Journal) error { if a.Request.Model != "" { args = append(args, "--model", a.Request.Model) } + // A named quality preference belongs to B's crew and provider catalog. + if choice, ok := crewroute.ParseEffort(a.Request.Effort); ok && choice != crewroute.EffortKnee { + args = append(args, "--"+string(choice)) + } // A brief can itself be a flag-shaped word; it remains positional text. args = append(args, "--", a.Request.Instruction) var cmd *exec.Cmd diff --git a/internal/taskplace/computers.go b/internal/taskplace/computers.go new file mode 100644 index 0000000000..93e82c8ebe --- /dev/null +++ b/internal/taskplace/computers.go @@ -0,0 +1,89 @@ +// Package taskplace adapts paired task doors to the conversation's run engine. +package taskplace + +import ( + "context" + "errors" + "sort" + "strings" + + "github.com/Agent-Field/codeaf/internal/devname" + "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/taskdoor" +) + +// Computers uses the same identity roster as pairing. The separate machine +// access book is deliberately uninvolved: pairing is the task permission. +type Computers struct { + Directory directory.Client + Copies taskdoor.CopyDeps + Home string + Clock session.TaskWatchClock +} + +func (c *Computers) ThisComputer() string { return devname.Name(c.Home) } +func (c *Computers) List(ctx context.Context) ([]session.TaskComputer, error) { + listing, err := c.Directory.List(ctx) + if err != nil { + return nil, err + } + key := directory.MetadataKey(c.Copies.Identity.CellKey()) + var out []session.TaskComputer + for id, device := range listing.Devices { + if device.Revoked || id == c.Copies.Device.ID() { + continue + } + name, _ := directory.OpenName(key, device.Name) + if strings.TrimSpace(name) == "" { + name = "a device" + } + row := session.TaskComputer{Device: id, Name: devname.Shown(name), CanTakeTasks: device.Platform != "windows"} + if !row.CanTakeTasks { + row.Reason = UnsupportedTarget(row.Name) + } + out = append(out, row) + } + sort.Slice(out, func(i, j int) bool { + if out[i].Name == out[j].Name { + return out[i].Device < out[j].Device + } + return out[i].Name < out[j].Name + }) + return out, nil +} +func UnsupportedTarget(name string) string { + return name + " cannot take tasks from other computers yet" +} +func (c *Computers) Engine(ctx context.Context, device string) (session.TaskRunEngine, error) { + computers, err := c.List(ctx) + if err != nil { + return nil, err + } + for _, computer := range computers { + if computer.Device == device { + if !computer.CanTakeTasks { + return nil, errors.New(computer.Reason) + } + return c.RecoverEngine(session.TaskExecutor{Device: computer.Device, Name: computer.Name}), nil + } + } + return nil, errors.New(taskdoor.RemovedComputerLine) +} + +// RecoverEngine needs only the saved executor to install an offline watcher or +// compare the local copy. Each dial checks current roster permission separately. +func (c *Computers) RecoverEngine(executor session.TaskExecutor) session.TaskRunEngine { + return &Engine{Copies: c.Copies, Computer: session.TaskComputer{Device: executor.Device, Name: executor.Name, CanTakeTasks: true}, OriginName: c.ThisComputer(), Clock: c.Clock, Allowed: func(ctx context.Context) error { + computers, err := c.List(ctx) + if err != nil { + return err + } + for _, computer := range computers { + if computer.Device == executor.Device && computer.CanTakeTasks { + return nil + } + } + return errors.New(taskdoor.RemovedComputerLine) + }} +} diff --git a/internal/taskplace/engine.go b/internal/taskplace/engine.go new file mode 100644 index 0000000000..1eb2232fbb --- /dev/null +++ b/internal/taskplace/engine.go @@ -0,0 +1,350 @@ +package taskplace + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/Agent-Field/codeaf/internal/plandb" + "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/taskdoor" +) + +// Engine owns the out/run/back road. It never runs tools on the origin; the +// session retains copy preparation, ledger ownership and local landing. +type Engine struct { + Copies taskdoor.CopyDeps + Computer session.TaskComputer + OriginName string + Clock session.TaskWatchClock + Allowed func(context.Context) error +} + +func (e *Engine) dial(ctx context.Context) (*taskdoor.Conn, error) { + if e.Allowed != nil { + if err := e.Allowed(ctx); err != nil { + return nil, err + } + } + return taskdoor.Dial(ctx, e.Copies.Service, e.Copies.Identity, e.Copies.Device, e.Computer.Device, e.Computer.Name) +} +func (e *Engine) Land(context.Context, *plandb.Store, string, string, string) (session.RunLanding, error) { + return session.RunLanding{}, errors.New("the returned task copy must be landed by this computer") +} + +func (e *Engine) Start(ctx context.Context, spec session.RunSpec) session.RunSummary { + record := session.TaskRemoteRecord{Executor: session.TaskExecutor{Device: e.Computer.Device, Name: e.Computer.Name}} + if spec.Remote != nil { + record = *spec.Remote + } + stopped := func() bool { return spec.RemoteStopped != nil && spec.RemoteStopped() } + if stopped() { + return session.RunSummary{Outcome: "stopped", KeepCopy: true, StorageSafe: true} + } + input, err := taskdoor.SendCopy(ctx, e.Copies, spec.Workspace) + if err != nil { + record.Fingerprint, _ = taskdoor.CopyFingerprint(ctx, spec.Workspace) + return e.startFailed(spec, record, err) + } + record.Fingerprint = input.Fingerprint + var nonce [8]byte + if _, err := rand.Read(nonce[:]); err != nil { + return e.startFailed(spec, record, err) + } + record.Attempt = "att_" + hex.EncodeToString(nonce[:]) + // Record before start as well as watch: a lost started reply is an uncertain + // attempt, and recovery must ask for it rather than launch another child. + if err := save(spec, record, true); err != nil { + return failed(err) + } + conn, err := e.dial(ctx) + if err != nil { + return e.startFailed(spec, record, err) + } + defer conn.Close() + if stopped() { + return session.RunSummary{Outcome: "stopped", KeepCopy: true, StorageSafe: true} + } + request := taskdoor.Start{Chat: spec.Conversation, Task: int(spec.TaskNumber), Title: spec.Title, Instruction: spec.Brief, Model: spec.NamedModel, Effort: spec.NamedEffort, MaxSteps: spec.StepsPerTask, NoProgress: spec.NoProgress, Input: input, Limits: taskdoor.Limits{USD: spec.CostUSD, Hours: spec.Elapsed.Hours()}, OriginName: e.OriginName} + startCtx, cancel := context.WithTimeout(ctx, taskdoor.DialWithin) + stop := context.AfterFunc(startCtx, func() { _ = conn.Close() }) + err = conn.Send(record.Attempt, request) + var frame taskdoor.Frame + if err == nil { + frame, err = conn.Receive() + } + stop() + cancel() + if err != nil { + return e.startFailed(spec, record, err) + } + if frame.Type != "started" { + var no taskdoor.Refused + if frame.Type == "refused" && frame.Decode(&no) == nil && no.Reason != "" { + return e.startFailed(spec, record, errors.New(no.Reason)) + } + return e.startFailed(spec, record, errors.New(taskdoor.DifferentVersion(e.Computer.Name))) + } + if stopped() { + stopCtx, cancel := context.WithTimeout(context.Background(), taskdoor.DialWithin) + _ = e.Stop(stopCtx, record) + cancel() + } + if spec.OnRemotePhase != nil { + spec.OnRemotePhase(session.TaskRunningOn(e.Computer.Name)) + } + _ = conn.Close() + return e.follow(ctx, spec, record) +} +func save(spec session.RunSpec, record session.TaskRemoteRecord, force bool) error { + if spec.SaveRemote != nil { + return spec.SaveRemote(record, force) + } + return errors.New("the task's remote record cannot be saved here") +} +func failed(err error) session.RunSummary { + return session.RunSummary{Outcome: "incomplete", Result: err.Error(), KeepCopy: true} +} +func (e *Engine) startFailed(spec session.RunSpec, record session.TaskRemoteRecord, err error) session.RunSummary { + record.StartFailed = true + if saveErr := save(spec, record, true); saveErr != nil { + return failed(saveErr) + } + if spec.OnRemotePhase != nil { + spec.OnRemotePhase(err.Error() + " — run it here") + } + return failed(err) +} +func (e *Engine) Resume(ctx context.Context, spec session.RunSpec) session.RunSummary { + if spec.Remote == nil { + return failed(errors.New("the task's remote record is missing")) + } + if spec.Remote.Abandoned { + return failed(session.ErrTaskAttemptAbandoned) + } + return e.follow(ctx, spec, *spec.Remote) +} +func (e *Engine) follow(ctx context.Context, spec session.RunSpec, record session.TaskRemoteRecord) session.RunSummary { + var final taskdoor.Finished + if len(record.Final) > 0 { + if err := json.Unmarshal(record.Final, &final); err != nil { + return failed(err) + } + } else { + var current taskdoor.Frame + attempt := record.Attempt + watch := func(ctx context.Context, from uint64, alive func(), deliver func(uint64) error) error { + conn, err := e.dial(ctx) + if err != nil { + return err + } + defer conn.Close() + if err = conn.Send(attempt, taskdoor.Watch{From: from}); err != nil { + return err + } + alive() + // Conn consumes ping/pong itself. Poll its actual activity rather than + // mistaking a quiet model call for a computer that stopped answering. + keepCtx, cancel := context.WithCancel(ctx) + keepDone := make(chan struct{}) + defer func() { cancel(); <-keepDone }() + go func() { + defer close(keepDone) + tick := time.NewTicker(time.Second) + defer tick.Stop() + last := conn.LastActivity() + for { + select { + case <-keepCtx.Done(): + return + case <-tick.C: + now := conn.LastActivity() + if now.After(last) { + last = now + alive() + } + } + } + }() + for { + frame, err := conn.Receive() + if err != nil { + return err + } + alive() + if frame.Attempt != attempt { + continue + } + if frame.Type == "unknown" { + current = frame + if err := deliver(from + 1); err != nil { + return err + } + return session.ErrTaskWatchEnded + } + if frame.Seq <= from { + continue + } + current = frame + if err = deliver(frame.Seq); err != nil { + return err + } + if frame.Type == "finished" { + return session.ErrTaskWatchEnded + } + } + } + consume := func(seq uint64) error { + record.Unreachable = false + switch current.Type { + case "unknown": + final = taskdoor.Finished{Outcome: "interrupted", Report: "the other computer no longer has this task's answer"} + record.Final, _ = json.Marshal(final) + record.Finished = true + case "step": + var event taskdoor.Step + if err := current.Decode(&event); err != nil { + return err + } + event.Step.FullOutput = "" + // Only the root trajectory travels; B's child ids name no local rows. + event.Step.Children = nil + raw, _ := json.Marshal(event.Step) + if spec.OnRemoteStep != nil { + if err := spec.OnRemoteStep(seq, raw); err != nil { + return err + } + } + case "spend": + var event taskdoor.Spend + if err := current.Decode(&event); err != nil { + return err + } + line := event.Line + charge := session.RunCharge{Model: line.Model, TokensIn: line.InputTokens, TokensOut: line.OutputTokens, Cached: line.CachedTokens, USD: line.USD} + if spec.OnRemoteCharge != nil { + if err := spec.OnRemoteCharge(seq, charge, line.Seat); err != nil { + return err + } + } + record.USD += line.USD + case "phase": + var phase taskdoor.Phase + if err := current.Decode(&phase); err != nil { + return err + } + if spec.OnRemotePhase != nil { + spec.OnRemotePhase(phase.Words) + } + case "finished": + if err := current.Decode(&final); err != nil { + return err + } + record.Final, _ = json.Marshal(final) + record.Finished = true + default: + return fmt.Errorf("the other computer sent an unreadable task event") + } + record.Seq = seq + return save(spec, record, current.Type == "finished") + } + reach := func(missing bool) { + record.Unreachable = missing + _ = save(spec, record, true) + if spec.OnRemotePhase != nil { + words := session.TaskRunningOn(e.Computer.Name) + if missing { + words = session.TaskComputerStoppedAnswering(e.Computer.Name) + } + spec.OnRemotePhase(words) + } + } + if err := session.FollowTaskWatch(ctx, e.Clock, record.Seq, watch, consume, reach); err != nil { + _ = save(spec, record, true) + return session.RunSummary{Outcome: "interrupted", Result: err.Error(), USD: record.USD, KeepCopy: true} + } + } + if final.Result == nil && final.Outcome != "interrupted" { + return session.RunSummary{Outcome: "incomplete", Result: final.Report + "\nThe result could not be brought from " + e.Computer.Name + "; the copy here was kept", USD: record.USD, KeepCopy: true, NeedsDecision: true} + } + if final.Result != nil && !record.Restored { + apply := func() error { + return taskdoor.ReceiveResult(ctx, e.Copies, *final.Result, spec.Workspace, record.Fingerprint) + } + var err error + if spec.WithRemoteCopy != nil { + err = spec.WithRemoteCopy(apply) + } else { + err = apply() + } + if err != nil { + summary := failed(err) + summary.USD = record.USD + summary.NeedsDecision = errors.Is(err, taskdoor.ErrCopyChanged) + return summary + } + record.Restored = true + } + record.Finished = true + record.Unreachable = false + if err := save(spec, record, true); err != nil { + return failed(err) + } + + outcome := final.Outcome + switch outcome { + case "done": + _ = spec.Store.CompleteRoot(final.Report) + case "stopped": + _ = spec.Store.StopRoot(outcome) + case "incomplete", "interrupted": + _ = spec.Store.EndRoot(outcome) + default: + outcome = "incomplete" + _ = spec.Store.EndRoot(outcome) + } + if spec.OnRemotePhase != nil { + spec.OnRemotePhase(session.TaskRanOn(e.Computer.Name)) + } + return session.RunSummary{Outcome: outcome, Result: final.Report, Model: final.Model, Nodes: 1, USD: record.USD, StorageSafe: true, KeepCopy: outcome == "interrupted"} +} +func (e *Engine) control(ctx context.Context, record session.TaskRemoteRecord, payload any) error { + if record.Attempt == "" { + return nil + } + conn, err := e.dial(ctx) + if err != nil { + return err + } + defer conn.Close() + if err = conn.Send(record.Attempt, payload); err != nil { + return err + } + frame, err := conn.Receive() + if err != nil { + return err + } + if frame.Type == "refused" { + var no taskdoor.Refused + _ = frame.Decode(&no) + return errors.New(no.Reason) + } + return nil +} +func (e *Engine) Stop(ctx context.Context, r session.TaskRemoteRecord) error { + return e.control(ctx, r, taskdoor.Stop{}) +} +func (e *Engine) Abandon(ctx context.Context, r session.TaskRemoteRecord) error { + _ = e.Stop(ctx, r) + return e.control(ctx, r, taskdoor.Ack{Disposition: "abandoned"}) +} +func (e *Engine) Acknowledge(ctx context.Context, r session.TaskRemoteRecord) error { + return e.control(ctx, r, taskdoor.Ack{Disposition: "landed"}) +} +func (e *Engine) CheckCopy(ctx context.Context, workspace, fingerprint string) error { + return taskdoor.CheckCopy(ctx, workspace, fingerprint) +} diff --git a/internal/taskplace/engine_test.go b/internal/taskplace/engine_test.go new file mode 100644 index 0000000000..4cb7f60ee5 --- /dev/null +++ b/internal/taskplace/engine_test.go @@ -0,0 +1,257 @@ +//go:build linux || darwin + +package taskplace_test + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/blobstore" + "github.com/Agent-Field/codeaf/internal/cellstore" + "github.com/Agent-Field/codeaf/internal/devname" + "github.com/Agent-Field/codeaf/internal/furrow" + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/plandb" + "github.com/Agent-Field/codeaf/internal/relay" + "github.com/Agent-Field/codeaf/internal/run" + "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/taskdoor" + "github.com/Agent-Field/codeaf/internal/taskplace" +) + +// The helper is a real isolated child driven only by B's injected process +// seam. A transcript sentinel on A is never among its task-copy inputs. +func TestOtherHomeTaskChild(t *testing.T) { + if os.Getenv("CODEAF_OTHER_HOME_CHILD") != "1" { + return + } + value := func(flag string) string { + for i, arg := range os.Args { + if arg == flag && i+1 < len(os.Args) { + return os.Args[i+1] + } + } + return "" + } + work, record := value("--dir"), value("--task-store") + if work == "" || record == "" { + os.Exit(1) + } + var request taskdoor.Start + raw, err := os.ReadFile(filepath.Join(filepath.Dir(record), "request.json")) + if err != nil || json.Unmarshal(raw, &request) != nil || request.Instruction != "settled instruction with acceptance and checks" || request.Chat != "chat-on-A" || request.Task != 1 || request.Model != "B/model" || request.Effort != "cheap" || request.MaxSteps != 17 || request.NoProgress != 9 || request.Limits.USD != .75 || request.Limits.Hours != .5 || request.OriginName != "chat computer" { + os.Exit(2) + } + if value("--model") != "B/model" { + os.Exit(6) + } + cheap := false + for _, arg := range os.Args { + if arg == "--cheap" { + cheap = true + } + } + if !cheap { + os.Exit(7) + } + if _, err := os.Stat(filepath.Join(work, "conversation-only.txt")); !os.IsNotExist(err) { + os.Exit(3) + } + dir := filepath.Join(record, "tasks", "1") + _ = os.MkdirAll(dir, 0700) + file, _ := os.Create(filepath.Join(dir, "trajectory.jsonl")) + enc := json.NewEncoder(file) + _ = enc.Encode(run.Step{Kind: "step", Step: 1, Command: "inspect on B", Observation: "first", RecordedOrder: 1, FullOutput: filepath.Join(record, "private-output")}) + _ = enc.Encode(run.Step{Kind: "step", Step: 2, Command: "write on B", Observation: "second", RecordedOrder: 2}) + _ = file.Close() + // The child cannot finish until A has actually consumed both live steps. + // Buffering the trajectory until finished would therefore fail this proof. + gate := os.Getenv("CODEAF_OTHER_HOME_GATE") + deadline := time.Now().Add(15 * time.Second) + for { + if _, err := os.Stat(gate); err == nil { + break + } + if time.Now().After(deadline) { + os.Exit(8) + } + time.Sleep(10 * time.Millisecond) + } + if err := os.WriteFile(filepath.Join(work, "made-on-B.txt"), []byte("B's child wrote this\n"), 0644); err != nil { + os.Exit(4) + } + gitChild := func(args ...string) { + cmd := exec.Command("git", append([]string{"-C", work, "-c", "user.name=task test", "-c", "user.email=task@example.test"}, args...)...) + if out, err := cmd.CombinedOutput(); err != nil { + fmt.Fprintln(os.Stderr, string(out)) + os.Exit(5) + } + } + gitChild("add", "made-on-B.txt") + gitChild("commit", "-qm", "change made on B") + _ = json.NewEncoder(os.Stdout).Encode(map[string]any{"ok": true, "answer": "the change is made on B", "model": "B/model", "files": []string{"made-on-B.txt"}}) + os.Exit(0) +} + +// Contracts 1–3 use the real relay, authenticated door, child executor and +// encrypted copy transport. No remote engine or filesystem transfer is faked. +func TestATaskSentToTheOtherHomeRunsThereAndItsChangeComesBack(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("CODEAF_HOME", t.TempDir()) + t.Setenv("CODEAF_TELEMETRY", "off") + t.Setenv("DO_NOT_TRACK", "1") + shortHome := func(prefix string) string { + path, err := os.MkdirTemp("", prefix) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.RemoveAll(path) }) + return path + } + homeA, homeB := shortHome("rn-l1-a-"), shortHome("rn-l1-b-") + t.Setenv("HOME", homeA) + t.Setenv("CODEAF_HOME", homeA) + id, err := identity.Ensure(homeA) + if err != nil { + t.Fatal(err) + } + a, err := identity.LoadDevice(homeA) + if err != nil { + t.Fatal(err) + } + b, err := identity.NewDevice(id) + if err != nil { + t.Fatal(err) + } + if err := identity.Install(homeB, id, b); err != nil { + t.Fatal(err) + } + _, _ = devname.Set(homeB, "work computer") + relayServer := httptest.NewServer(&relay.Server{}) + defer relayServer.Close() + defer relayServer.CloseClientConnections() + memory := blobstore.NewMemory() + blobs := httptest.NewServer(blobstore.Handler(func(*http.Request, []byte) (string, string, error) { return id.ID(), a.ID(), nil }, func(string) (blobstore.Store, error) { return memory, nil })) + defer blobs.Close() + bin, err := furrow.ResolveOwned() + if err != nil { + t.Fatal(err) + } + makeCopy := func(home string, device identity.Dev) taskdoor.CopyDeps { + socketHome, err := os.MkdirTemp("", "rn-l1-") + if err != nil { + t.Fatal(err) + } + daemon := cellstore.Daemon{Binary: bin, Socket: filepath.Join(socketHome, "e.sock")} + t.Cleanup(func() { _ = daemon.Stop(context.Background()); _ = os.RemoveAll(socketHome) }) + engine := cellstore.EngineFor("") + engine.Binary = bin + engine.Transport = daemon + engine.DataRoot = filepath.Join(home, "stores") + engine.Guard.Home = home + return taskdoor.CopyDeps{Root: filepath.Join(home, "copies"), Service: relayServer.URL, Identity: id, Device: device, Engine: engine, Store: blobstore.NewHTTP(blobs.URL, func(*http.Request, []byte) {}, blobs.Client())} + } + copiesA, copiesB := makeCopy(homeA, a), makeCopy(homeB, b) + executor := taskdoor.NewExecutor(taskdoor.ExecutorConfig{Copies: copiesB, Ledger: filepath.Join(homeB, "usage.jsonl"), Command: func(args []string) *exec.Cmd { + cmd := exec.Command(os.Args[0], append([]string{"-test.run=^TestOtherHomeTaskChild$", "--"}, args...)...) + cmd.Env = append(os.Environ(), "CODEAF_OTHER_HOME_CHILD=1", "HOME="+homeB, "CODEAF_HOME="+homeB, "CODEAF_OTHER_HOME_GATE="+filepath.Join(homeB, "steps-arrived")) + return cmd + }}) + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() + serving := make(chan error, 1) + ready := make(chan struct{}, 1) + go func() { + defer close(serving) + serving <- taskdoor.Serve(ctx, taskdoor.Config{Service: relayServer.URL, Identity: id, Device: b, Home: homeB, Root: filepath.Join(homeB, "door"), Executor: executor, Roster: func(context.Context) (map[string]bool, error) { + return map[string]bool{a.ID(): true, b.ID(): true}, nil + }, Say: func(line string) { + if line == taskdoor.TakingTasksLine { + select { + case ready <- struct{}{}: + default: + } + } + }}) + }() + defer func() { + cancel() + if err := <-serving; err != nil { + t.Error(err) + } + }() + select { + case <-ready: + case err := <-serving: + t.Fatalf("serve: %v", err) + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } + work := t.TempDir() + git := func(args ...string) string { + cmd := exec.Command("git", append([]string{"-C", work, "-c", "user.name=task test", "-c", "user.email=task@example.test"}, args...)...) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %v: %v: %s", args, err, out) + } + return strings.TrimSpace(string(out)) + } + git("init", "-qb", "task-copy") + _ = os.WriteFile(filepath.Join(work, "input.txt"), []byte("input\n"), 0644) + git("add", "input.txt") + git("commit", "-qm", "input") + _ = os.WriteFile(filepath.Join(homeA, "conversation-only.txt"), []byte("A's transcript stays here"), 0600) + store, err := plandb.Open(filepath.Join(t.TempDir(), "plandb.db"), "task", "1", "change", "brief") + if err != nil { + t.Fatal(err) + } + defer store.Close() + remote := &taskplace.Engine{Copies: copiesA, Computer: session.TaskComputer{Device: b.ID(), Name: "work computer", CanTakeTasks: true}, OriginName: "chat computer"} + var saved session.TaskRemoteRecord + var commands []string + summary := remote.Start(ctx, session.RunSpec{Store: store, Workspace: work, Conversation: "chat-on-A", TaskNumber: 1, Title: "change", Brief: "settled instruction with acceptance and checks", NamedModel: "B/model", NamedEffort: "cheap", StepsPerTask: 17, NoProgress: 9, CostUSD: .75, Elapsed: time.Minute * 30, SaveRemote: func(r session.TaskRemoteRecord, force bool) error { saved = r; return nil }, OnRemoteStep: func(seq uint64, raw json.RawMessage) error { + if saved.Attempt == "" { + t.Error("watch began before persistence") + } + var step run.Step + if err := json.Unmarshal(raw, &step); err != nil { + return err + } + if step.FullOutput != "" { + t.Error("remote output path offered locally") + } + commands = append(commands, step.Command) + if len(commands) == 2 { + return os.WriteFile(filepath.Join(homeB, "steps-arrived"), []byte("A has both live steps"), 0600) + } + return nil + }}) + if summary.Outcome != "done" || summary.Result != "the change is made on B" { + t.Fatalf("summary = %+v", summary) + } + body, err := os.ReadFile(filepath.Join(work, "made-on-B.txt")) + if err != nil || string(body) != "B's child wrote this\n" { + t.Fatalf("returned file = %q, %v", body, err) + } + if got := git("log", "-1", "--format=%s"); got != "change made on B" { + t.Fatalf("returned commit = %s", got) + } + if strings.Join(commands, ",") != "inspect on B,write on B" { + t.Fatalf("steps = %v", commands) + } + if !saved.Restored || saved.Seq == 0 { + t.Fatalf("record = %+v", saved) + } + if err := remote.Acknowledge(ctx, saved); err != nil { + t.Fatal(err) + } +} From 6d5488d657144b972d8f587febc6fee9b5dc3dd6 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Fri, 30 Oct 2026 18:33:33 -0400 Subject: [PATCH 07/34] chat: wire paired task computers into v3 conversations A v3 conversation on a paired Linux or macOS home that is not quiet now carries its paired computers, so the computer argument and the setting reach a real door; anywhere else the conversation carries none and the feature is absent. Co-Authored-By: Claude Opus 5.5 --- cmd/codeaf/chatv3_orchestrate.go | 3 + cmd/codeaf/chatv3_taskcomputers.go | 44 +++++++++ cmd/codeaf/chatv3_taskcomputers_test.go | 115 ++++++++++++++++++++++++ 3 files changed, 162 insertions(+) create mode 100644 cmd/codeaf/chatv3_taskcomputers.go create mode 100644 cmd/codeaf/chatv3_taskcomputers_test.go diff --git a/cmd/codeaf/chatv3_orchestrate.go b/cmd/codeaf/chatv3_orchestrate.go index a9c1ea4a94..7f840e19f9 100644 --- a/cmd/codeaf/chatv3_orchestrate.go +++ b/cmd/codeaf/chatv3_orchestrate.go @@ -61,6 +61,9 @@ func v3OpenSession(cfg session.Config) (*session.Agent, error) { // Every conversation this process opens counts its task lanes in one // account, because they are all running on one machine. cfg.TaskLanes = v3MachineLanes + if cfg.TaskComputers == nil { + cfg.TaskComputers = v3TaskComputers(cfg.ProfileDir) + } cfg, runs := v3Adaptive(cfg) agent, err := session.New(cfg) if err != nil { diff --git a/cmd/codeaf/chatv3_taskcomputers.go b/cmd/codeaf/chatv3_taskcomputers.go new file mode 100644 index 0000000000..d52168fcc9 --- /dev/null +++ b/cmd/codeaf/chatv3_taskcomputers.go @@ -0,0 +1,44 @@ +package main + +import ( + "net/http" + "path/filepath" + "runtime" + "time" + + "github.com/Agent-Field/codeaf/internal/blobstore" + "github.com/Agent-Field/codeaf/internal/cellstore" + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/pair" + "github.com/Agent-Field/codeaf/internal/reqsign" + "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/syncsetup" + "github.com/Agent-Field/codeaf/internal/taskdoor" + "github.com/Agent-Field/codeaf/internal/taskplace" +) + +// v3TaskComputers builds no client while this home is quiet, unpaired or on an +// unsupported platform. A nil door also keeps the proposal argument absent. +func v3TaskComputers(profile string) session.TaskComputers { + if runtime.GOOS == "windows" || syncsetup.Quiet(profile) { + return nil + } + route := syncsetup.Resolve(profile) + if !route.On() { + return nil + } + client, id, err := homeDirectory(profile, pair.Mailbox{URL: route.URL}) + if err != nil { + return nil + } + device, err := identity.LoadDevice(profile) + if err != nil { + return nil + } + engine := cellstore.EngineFor("") + engine.DataRoot = filepath.Join(profile, "v3", "stores") + engine.Guard.Home = profile + store := blobstore.NewHTTP(route.URL, reqsign.SignFor(homeSigner{id, device}, time.Now), &http.Client{Timeout: time.Minute}) + copies := taskdoor.CopyDeps{Root: filepath.Join(profile, "v3", "taskcopies"), Service: route.URL, Identity: id, Device: device, Engine: engine, Store: store} + return &taskplace.Computers{Directory: client, Copies: copies, Home: profile} +} diff --git a/cmd/codeaf/chatv3_taskcomputers_test.go b/cmd/codeaf/chatv3_taskcomputers_test.go new file mode 100644 index 0000000000..f824f129b7 --- /dev/null +++ b/cmd/codeaf/chatv3_taskcomputers_test.go @@ -0,0 +1,115 @@ +package main + +import ( + "context" + "net/http/httptest" + "runtime" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/reqsign" +) + +// The absence law is applied at the actual conversation configuration door. +func TestConversationTaskComputersAreAbsentInUnpairedSoloAndSyncOffHomes(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("CODEAF_HOME", t.TempDir()) + t.Setenv("CODEAF_TELEMETRY", "off") + t.Setenv("DO_NOT_TRACK", "1") + for _, kind := range []string{"unpaired", "solo", "off"} { + t.Run(kind, func(t *testing.T) { + profile := t.TempDir() + if kind == "solo" { + if _, err := identity.EnsureSolo(profile); err != nil { + t.Fatal(err) + } + } + if kind == "off" { + if _, err := identity.Ensure(profile); err != nil { + t.Fatal(err) + } + t.Setenv("CODEAF_SYNC_URL", "off") + } + if got := v3TaskComputers(profile); got != nil { + t.Fatalf("%s offered task computers", kind) + } + }) + } +} + +// Contract 6 selects from the signed pairing roster, including unsupported +// Windows rows and excluding this computer and revoked device certificates. +func TestConversationTaskComputersUsePairingNamesAndRevocation(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("CODEAF_HOME", t.TempDir()) + t.Setenv("CODEAF_TELEMETRY", "off") + t.Setenv("DO_NOT_TRACK", "1") + profile := t.TempDir() + id, err := identity.Ensure(profile) + if err != nil { + t.Fatal(err) + } + self, err := identity.LoadDevice(profile) + if err != nil { + t.Fatal(err) + } + state := directory.NewMemory(time.Now) + client := state.For(self.ID()) + key := directory.MetadataKey(id.CellKey()) + var revoked string + for _, item := range []struct { + name, platform string + revoke bool + }{{"chat computer", "linux", false}, {"work computer", "darwin", false}, {"Windows computer", "windows", false}, {"removed computer", "linux", true}} { + dev := self + if item.name != "chat computer" { + dev, err = identity.NewDevice(id) + if err != nil { + t.Fatal(err) + } + } + name, err := directory.SealName(key, item.name) + if err != nil { + t.Fatal(err) + } + if err := client.PutDevice(context.Background(), dev.ID(), directory.Device{V: 1, Name: name, Platform: item.platform}); err != nil { + t.Fatal(err) + } + if item.revoke { + revoked = dev.ID() + if err := client.Revoke(context.Background(), dev.ID()); err != nil { + t.Fatal(err) + } + } + } + server := httptest.NewServer(directory.Handler(reqsign.AuthenticateAt(time.Now), func(string) (directory.Directory, error) { return state, nil })) + defer server.Close() + t.Setenv("CODEAF_SYNC_URL", server.URL) + computers := v3TaskComputers(profile) + if runtime.GOOS == "windows" { + if computers != nil { + t.Fatal("Windows origin offered remote tasks") + } + return + } + if computers == nil { + t.Fatal("paired home has no task computers") + } + rows, err := computers.List(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(rows) != 2 { + t.Fatalf("paired computers = %+v", rows) + } + for _, row := range rows { + if row.Device == self.ID() || row.Device == revoked { + t.Fatalf("non-target listed: %+v", row) + } + if row.Name == "Windows computer" && row.CanTakeTasks { + t.Fatal("Windows target can take tasks") + } + } +} From df0caf0b0023daefa90c63866a254a3fd2930bda Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Fri, 30 Oct 2026 19:34:38 -0400 Subject: [PATCH 08/34] session: carry effort and the card's computer choice through admission A task started on a named computer can now also be asked for the best or the cheaper crew, and a proposal's answer can carry the computer the person picked on its card; both go through the one placement resolution the computer argument uses, so there is no second road to the same choice. Co-Authored-By: Claude Opus 5.5 --- internal/session/question.go | 6 + internal/session/task.go | 6 + internal/session/task_computer_choice_test.go | 178 ++++++++++++++++++ internal/session/task_contract.go | 3 + internal/session/task_elsewhere.go | 14 +- 5 files changed, 206 insertions(+), 1 deletion(-) create mode 100644 internal/session/task_computer_choice_test.go diff --git a/internal/session/question.go b/internal/session/question.go index ed0fe4bf2e..e6f7e7164a 100644 --- a/internal/session/question.go +++ b/internal/session/question.go @@ -2170,6 +2170,7 @@ func (a *Agent) applyToLane(answer Answer) error { // The answers are on the row with their keys; the box is words. a.ResolveTask(answer.ID, TaskAnswer{ Approved: true, Redirect: words, Model: answer.Blanks[TaskModelBlank], + Computer: answer.Blanks[TaskComputerBlank], }) return nil } @@ -2193,6 +2194,7 @@ func (a *Agent) applyToLane(answer Answer) error { // option, which is what the card was showing. task := action.Task task.Model = answer.Blanks[TaskModelBlank] + task.Computer = answer.Blanks[TaskComputerBlank] a.ResolveTask(answer.ID, task) } return nil @@ -2781,6 +2783,10 @@ func (a *Agent) proposalQuestion(id uint64, notice TaskNotice) Question { // a choice somebody made and nothing acted on. const TaskModelBlank = "model" +// TaskComputerBlank carries the card's placement choice through the ordinary +// answer. Sharing the label keeps the surface and admission on the same choice. +const TaskComputerBlank = "computer" + // TaskModelPrompt is the sentence the hole sits in, with `{model}` where the // hole goes — so what a person reads is `run it on [ anthropic/claude-opus-5 ▾ ]` // rather than a form with a field name over it. diff --git a/internal/session/task.go b/internal/session/task.go index bc5b82f02b..a85022ba1a 100644 --- a/internal/session/task.go +++ b/internal/session/task.go @@ -885,6 +885,12 @@ func (p *stagedProposal) Commit(ctx context.Context) (string, bool, error) { } return withElsewhere("the person declined this task", elsewhere), false, nil } + // A card choice is explicit placement for this proposal only. Admission + // still resolves it through the same roster and eligibility checks as a + // computer originally named in the tool call or the typed task command. + if computer := strings.TrimSpace(answer.Computer); computer != "" { + spec.computer = computer + } if redirect := strings.TrimSpace(answer.Redirect); redirect != "" { // APPENDED, never merged into the brief's prose. The person's words // arrive last and in their own voice, so the node reads them as the diff --git a/internal/session/task_computer_choice_test.go b/internal/session/task_computer_choice_test.go new file mode 100644 index 0000000000..af6317a5cc --- /dev/null +++ b/internal/session/task_computer_choice_test.go @@ -0,0 +1,178 @@ +package session + +import ( + "context" + "encoding/json" + "strings" + "sync" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/config" +) + +func taskComputerChoiceHome(t *testing.T) { + t.Helper() + root := t.TempDir() + t.Setenv("HOME", root) + t.Setenv("CODEAF_HOME", root) +} + +// DESIGN contracts 1 and 6 require the named computer to receive the task's +// chosen controls; asking for effort must not route a worker on the origin. +func TestANamedComputerReceivesTheEffortChosenForThatTask(t *testing.T) { + for _, effort := range []string{"best", "cheap"} { + t.Run(effort, func(t *testing.T) { + taskComputerChoiceHome(t) + reached := make(chan string, 1) + remote := &scriptedTaskEngine{start: func(ctx context.Context, spec RunSpec) RunSummary { + reached <- spec.NamedEffort + _ = spec.Store.CompleteRoot("export repaired") + return RunSummary{Outcome: "done", Result: "export repaired"} + }} + a, local := remoteTaskAgent(t, remote, nil) + if _, _, _, err := a.StartTaskOn(context.Background(), "Repair export", true, "work computer", effort); err != nil { + t.Fatal(err) + } + select { + case got := <-reached: + if got != effort { + t.Fatalf("the other computer received effort %q, want %q", got, effort) + } + case <-time.After(5 * time.Second): + t.Fatal("the chosen computer did not receive the task") + } + waitRemoteRun(t, a) + if local.didRun() { + t.Fatal("the task ran on the origin") + } + }) + } +} + +// DESIGN contracts 1 and 7 require a card's explicit computer to outrank +// both the original proposal and the standing default, through real admission. +func TestAProposalAnswerChoosesTheComputerThroughThePlacementResolver(t *testing.T) { + for _, via := range []string{"task answer", "question answer", "correction"} { + for _, computer := range []string{"work computer", "this computer"} { + t.Run(via+"/"+computer, func(t *testing.T) { + taskComputerChoiceHome(t) + reached := make(chan string, 1) + remote := &scriptedTaskEngine{start: func(ctx context.Context, spec RunSpec) RunSummary { + reached <- "work computer" + _ = spec.Store.CompleteRoot("export repaired") + return RunSummary{Outcome: "done", Result: "export repaired"} + }} + a, local := remoteTaskAgent(t, remote, func(c *Config) { + c.TaskAutoApproveSeconds = 0 + c.AskConsent = true + if computer == "this computer" { + if err := config.WriteTaskRunsOn(c.ProfileDir, "dev_0123456789abcdef0123456789abcdef"); err != nil { + t.Fatal(err) + } + } + }) + var release sync.Once + t.Cleanup(func() { release.Do(func() { close(local.release) }) }) + args := taskOnOtherComputer() + args.Computer = "" + if computer == "this computer" { + args.Computer = "work computer" + } + raw, _ := json.Marshal(args) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + events := watched(a) + staged := a.stageTask(ctx, raw) + var id uint64 + for id == 0 { + select { + case event := <-events: + if event.Kind == EventTaskProposal && event.Task != nil { + id = event.Task.ID + } + case <-ctx.Done(): + t.Fatal("the proposal was not shown") + } + } + if via == "task answer" { + a.ResolveTask(id, TaskAnswer{Approved: true, Computer: computer}) + } else { + answer := Answer{Kind: QuestionTask, ID: id, Key: "1", Blanks: map[string]string{TaskComputerBlank: computer}} + if via == "correction" { + answer.Key = "" + answer.Change = "also cover an empty export" + } + if err := a.ResolveQuestion(answer); err != nil { + t.Fatal(err) + } + } + result, refused, err := staged.Commit(ctx) + if err != nil || refused { + t.Fatalf("proposal = %q, %v, %v", result, refused, err) + } + if computer == "work computer" { + select { + case <-reached: + case <-ctx.Done(): + t.Fatal("the card's choice did not reach the other computer") + } + if local.didRun() { + t.Fatal("the origin ran the explicitly remote task") + } + } else { + select { + case <-local.entered: + case <-ctx.Done(): + t.Fatal("the card's local choice did not override placement") + } + release.Do(func() { close(local.release) }) + select { + case <-reached: + t.Fatal("the local choice also ran remotely") + default: + } + } + waitRemoteRun(t, a) + }) + } + } +} + +// DESIGN contract 6 requires a corrected card choice to get the same +// correctable unknown-name answer as every other placement entry point. +func TestAnUnknownComputerChosenOnAProposalUsesThePlacementError(t *testing.T) { + taskComputerChoiceHome(t) + a, local := remoteTaskAgent(t, nil, func(c *Config) { + c.TaskAutoApproveSeconds = 0 + c.AskConsent = true + }) + var release sync.Once + t.Cleanup(func() { release.Do(func() { close(local.release) }) }) + events := watched(a) + args := taskOnOtherComputer() + args.Computer = "" + raw, _ := json.Marshal(args) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + staged := a.stageTask(ctx, raw) + for { + select { + case ev := <-events: + if ev.Kind != EventTaskProposal || ev.Task == nil { + continue + } + a.ResolveTask(ev.Task.ID, TaskAnswer{Approved: true, Computer: "missing"}) + result, refused, err := staged.Commit(ctx) + if err != nil || !refused || !strings.Contains(result, `no paired computer is named "missing"`) || !strings.Contains(result, "work computer") { + t.Fatalf("proposal correction = %q, %v, %v", result, refused, err) + } + if local.didRun() { + t.Fatal("an unknown choice started local work") + } + return + case <-ctx.Done(): + t.Fatal("the proposal was not shown") + } + } +} diff --git a/internal/session/task_contract.go b/internal/session/task_contract.go index 3292007b04..bc0606bbb0 100644 --- a/internal/session/task_contract.go +++ b/internal/session/task_contract.go @@ -863,6 +863,9 @@ type TaskAnswer struct { Approved bool Redirect string Model string + // Computer is an explicit card choice. Empty preserves the proposal and + // default; the local alias overrides both without naming another device. + Computer string `json:",omitempty"` } // ── the phases of a running node ──────────────────────────────────────────── diff --git a/internal/session/task_elsewhere.go b/internal/session/task_elsewhere.go index 64c8cf1b2f..d34deeacdd 100644 --- a/internal/session/task_elsewhere.go +++ b/internal/session/task_elsewhere.go @@ -10,6 +10,7 @@ import ( "time" "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/crewroute" ) // TaskComputerControlWithin bounds best-effort stop and acknowledgement calls. @@ -164,12 +165,19 @@ func (a *Agent) resolveTaskComputer(ctx context.Context, named string, stand tas // StartTaskOn is the shared session entry for a person's explicit placement. // Model proposals reach the same resolver through their normal admission road. -func (a *Agent) StartTaskOn(ctx context.Context, brief string, solo bool, computer string) (uint64, string, string, error) { +// The optional effort belongs to this task and leaves the standing crew alone. +func (a *Agent) StartTaskOn(ctx context.Context, brief string, solo bool, computer string, namedEffort ...string) (uint64, string, string, error) { + wish := crewWishOf(ctx) + if len(namedEffort) > 0 { + wish.effort, _ = crewroute.ParseEffort(namedEffort[0]) + ctx = withCrewWish(ctx, wish) + } stand := a.taskGroundOrStandingIn(taskSpec{title: taskPersonTitle(brief), request: brief, brief: brief, acceptance: taskPersonAcceptance}) p, err := a.resolveTaskComputer(ctx, computer, stand, "", false) if err != nil { return 0, "", "", err } + p.effort = string(wish.effort) var id uint64 var title, note string if p.computer != nil { @@ -180,6 +188,10 @@ func (a *Agent) StartTaskOn(ctx context.Context, brief string, solo bool, comput return id, title, withReport(note, p.sentence), err } +// TaskComputersSupported lets a connected window omit controls that this +// conversation cannot honor, even though the agent has the method shapes. +func (a *Agent) TaskComputersSupported() bool { return a.config.TaskComputers != nil } + var errRemotePlacementBusy = errors.New("the work already underway uses another computer; let it finish before starting this task") // cloneTaskRemote keeps published rows immutable while the watcher advances. From a89a467667341c37ed3d67048fc88d314848f743 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Fri, 30 Oct 2026 19:34:38 -0400 Subject: [PATCH 09/34] remote: forward task placement to a window on this computer's engine A linked-local window reaches its engine through the connection, so named starts and run it here travel as two new methods, Task.StartOn and Task.RunHere, behind an optional welcome capability. Task.Start and every existing answer keep their bytes and meaning, and a window attached to an older engine that does not advertise the capability shows neither control. The welcome now records a watching window before it is built, so a read-only watcher is never offered controls it cannot use. Co-Authored-By: Claude Opus 5.5 --- internal/remote/callclass.go | 2 +- internal/remote/server.go | 11 +- internal/remote/taskcomputers.go | 120 ++++++++++++++ internal/remote/taskcomputers_test.go | 221 ++++++++++++++++++++++++++ internal/remote/tasklane.go | 1 + internal/remote/wire.go | 3 + internal/remote/wire_task.go | 21 ++- 7 files changed, 371 insertions(+), 8 deletions(-) create mode 100644 internal/remote/taskcomputers.go create mode 100644 internal/remote/taskcomputers_test.go diff --git a/internal/remote/callclass.go b/internal/remote/callclass.go index 1cb6845bc5..b4b83fab0e 100644 --- a/internal/remote/callclass.go +++ b/internal/remote/callclass.go @@ -185,7 +185,7 @@ func classify(method string) callClass { MethodAutomationsDelete, MethodAutomationsRunNow, MethodAutomationsStopRun, MethodAutomationsClaim: return classAct - case MethodTaskWorkspaceReview, MethodTaskWorkspaceApply, MethodCompact, MethodOpenPlanTaskWork: + case MethodTaskWorkspaceReview, MethodTaskWorkspaceApply, MethodCompact, MethodOpenPlanTaskWork, MethodTaskRunHere: return classWork default: return classOrdered diff --git a/internal/remote/server.go b/internal/remote/server.go index 9228399d30..5e8eebb45a 100644 --- a/internal/remote/server.go +++ b/internal/remote/server.go @@ -1094,6 +1094,9 @@ func (sess *Session) attach(s *server, hello Hello) error { // names the surfaces that have already drawn it (held.go). s.name = machineLabel(hello.Surface) s.clientID = strings.TrimSpace(hello.ClientID) + // Read-only controls must already be absent from the initial welcome; + // recording this after it was built advertised controls a watcher cannot use. + s.watching = hello.Watch // THE ARRIVAL IS AN ACT. A window that has just been welcomed is // watching, and a pipe that tears on the way in — or a getter that // no longer crosses the wire — must not read as nobody having been @@ -1116,7 +1119,6 @@ func (sess *Session) attach(s *server, hello Hello) error { // new one (driver.go states the whole rule, [Hello.Back] states why the // difference is load-bearing). A WATCHER IS NEITHER: it never drives, and the // flag is recorded so no later hand-on can give it the keyboard either. - s.watching = hello.Watch if hello.Join { // WHAT THIS CONNECTION IS BOUND TO is the conversation it named, confirmed // against the one open under this same lock a few lines above. The engine's @@ -1296,6 +1298,7 @@ func (sess *Session) welcomeLocked(s *server) Welcome { SteerOwner: true, TaskSetup: taskSetupKnown(sess.agent), TaskRetry: taskRetryKnown(sess.agent), + TaskComputers: !s.watching && sess.engine.SessionFile != "" && taskComputersKnown(sess.agent), TaskWorkspace: taskWorkspaceKnown(sess.agent), // Whether this engine has a dial on the conversation's own thinking, // asked of the agent it has open — for [Welcome.Effort]'s stated reason: @@ -2133,13 +2136,15 @@ func (s *server) invoke(call Frame) (out json.RawMessage, err error) { // in the room: a watcher is a person watching their own work, not a guest. switch call.Method { case MethodSubmitBash, MethodSubmitSetup, MethodSubmit, MethodFollowUp, MethodUnqueueFollowUp, MethodSteer, MethodQuestionReplace, MethodSubmitImage, MethodSubmitFiles, - MethodTaskSteer, MethodTaskStop, MethodTaskRetry, MethodTaskWorkspaceReview, MethodTaskWorkspaceApply: + MethodTaskSteer, MethodTaskStop, MethodTaskRetry, MethodTaskWorkspaceReview, MethodTaskWorkspaceApply, MethodTaskStartOn, MethodTaskRunHere: if err := s.mayDrive(); err != nil { return nil, err } } switch call.Method { + case MethodTaskStartOn, MethodTaskRunHere: + return s.taskComputerInvoke(call) case MethodTaskWorkspaceReview, MethodTaskWorkspaceApply, MethodTaskWorkspaceCancel: args, err := arg[taskWorkspaceArgs](call) if err != nil { @@ -2437,7 +2442,7 @@ func (s *server) invoke(call Frame) (out json.RawMessage, err error) { return nil, errors.New("engine: this session has no task proposals to answer") } door.ResolveTask(args.ID, session.TaskAnswer{ - Approved: args.Approved, Redirect: args.Redirect, Model: args.Model, + Approved: args.Approved, Redirect: args.Redirect, Model: args.Model, Computer: args.Computer, }) return nil, nil case MethodTaskSettle: diff --git a/internal/remote/taskcomputers.go b/internal/remote/taskcomputers.go new file mode 100644 index 0000000000..1717318159 --- /dev/null +++ b/internal/remote/taskcomputers.go @@ -0,0 +1,120 @@ +package remote + +import ( + "context" + "encoding/json" + "errors" + + "github.com/Agent-Field/codeaf/internal/session" +) + +const taskComputersUnavailable = "paired task computers are unavailable here" + +type taskComputerDoor interface { + TaskComputersSupported() bool + StartTaskOn(context.Context, string, bool, string, ...string) (uint64, string, string, error) + RunTaskHere(context.Context, uint64) (string, error) +} + +func taskComputersKnown(agent any) bool { + door, ok := agent.(taskComputerDoor) + return ok && door.TaskComputersSupported() +} + +// An omitted welcome field means the engine does not understand these new +// requests. A client method's presence alone must never enable its UI control. +func (a *Agent) TaskComputersSupported() bool { return a.c.Welcome().TaskComputers } + +func (a *Agent) StartTaskOn(ctx context.Context, brief string, solo bool, computer string, effort ...string) (uint64, string, string, error) { + word := "" + if len(effort) > 0 { + word = effort[0] + } + return a.StartTaskOnIn(ctx, brief, solo, computer, word, a.c.Welcome().SessionFile) +} + +// The captured conversation survives a window switch while its start command +// is queued, so the new request cannot commission work in the replacement chat. +func (a *Agent) StartTaskOnIn(ctx context.Context, brief string, solo bool, computer, effort, conversation string) (uint64, string, string, error) { + if !a.TaskComputersSupported() { + return 0, "", "", errors.New(taskComputersUnavailable) + } + raw, err := a.callTaskStart(ctx, MethodTaskStartOn, TaskStartOnArgs{Brief: brief, Solo: solo, Computer: computer, Effort: effort, Session: conversation}) + if err != nil { + return 0, "", "", err + } + var reply TaskStarted + if err := json.Unmarshal(raw, &reply); err != nil { + return 0, "", "", err + } + return reply.ID, reply.Title, reply.Note, nil +} + +func (a *Agent) RunTaskHere(ctx context.Context, id uint64) (string, error) { + return a.RunTaskHereIn(ctx, id, a.c.Welcome().SessionFile) +} + +// Recovery carries the same owner binding as a named start. The existing +// session operation owns abandonment and local admission, not the connection. +func (a *Agent) RunTaskHereIn(ctx context.Context, id uint64, conversation string) (string, error) { + if !a.TaskComputersSupported() { + return "", errors.New(taskComputersUnavailable) + } + raw, err := a.c.call(ctx, MethodTaskRunHere, TaskSetupArgs{ID: id, Session: conversation}) + if err != nil { + return "", err + } + var reply TaskStarted + if err := json.Unmarshal(raw, &reply); err != nil { + return "", err + } + return reply.Note, nil +} + +func (s *server) taskComputerOwner(conversation string) (taskComputerDoor, error) { + want, agreed := steerConversation(s.joined, conversation) + if !agreed || want == "" { + return nil, session.ErrNotThatConversation + } + owner, mine := s.session.agentOf(want) + if !mine { + return nil, session.ErrNotThatConversation + } + if !taskComputersKnown(owner) { + return nil, errors.New(taskComputersUnavailable) + } + return owner.(taskComputerDoor), nil +} + +func (s *server) taskComputerInvoke(call Frame) (json.RawMessage, error) { + if call.Method == MethodTaskStartOn { + args, err := arg[TaskStartOnArgs](call) + if err != nil { + return nil, err + } + door, err := s.taskComputerOwner(args.Session) + if err != nil { + return nil, err + } + id, title, note, err := door.StartTaskOn(context.Background(), args.Brief, args.Solo, args.Computer, args.Effort) + if err != nil { + return nil, err + } + return json.Marshal(TaskStarted{ID: id, Title: title, Note: note}) + } + args, err := arg[TaskSetupArgs](call) + if err != nil { + return nil, err + } + door, err := s.taskComputerOwner(args.Session) + if err != nil { + return nil, err + } + words, err := door.RunTaskHere(context.Background(), args.ID) + if err != nil { + return nil, err + } + return json.Marshal(TaskStarted{ID: args.ID, Note: words}) +} + +var _ taskComputerDoor = (*session.Agent)(nil) diff --git a/internal/remote/taskcomputers_test.go b/internal/remote/taskcomputers_test.go new file mode 100644 index 0000000000..e665b58ad8 --- /dev/null +++ b/internal/remote/taskcomputers_test.go @@ -0,0 +1,221 @@ +package remote + +import ( + "context" + "encoding/json" + "strings" + "sync" + "testing" + + "github.com/Agent-Field/codeaf/internal/session" +) + +type pairedTaskWireAgent struct { + *fakeAgent + computerMu sync.Mutex + computer, effort, brief string + solo bool + here uint64 + answer session.TaskAnswer + available bool +} + +func taskComputerWireHome(t *testing.T) { + t.Helper() + root := t.TempDir() + t.Setenv("HOME", root) + t.Setenv("CODEAF_HOME", root) +} + +func (a *pairedTaskWireAgent) TaskComputersSupported() bool { return a.available } +func (a *pairedTaskWireAgent) StartTaskOn(_ context.Context, brief string, solo bool, computer string, effort ...string) (uint64, string, string, error) { + a.computerMu.Lock() + defer a.computerMu.Unlock() + a.brief, a.solo, a.computer = brief, solo, computer + if len(effort) > 0 { + a.effort = effort[0] + } + return 7, "Repair export", "the task works in its own copy", nil +} +func (a *pairedTaskWireAgent) RunTaskHere(_ context.Context, id uint64) (string, error) { + a.computerMu.Lock() + defer a.computerMu.Unlock() + a.here = id + return "running task 7 Repair export here", nil +} +func (a *pairedTaskWireAgent) ResolveTask(_ uint64, answer session.TaskAnswer) { + a.computerMu.Lock() + defer a.computerMu.Unlock() + a.answer = answer +} + +// DESIGN contracts 1, 6 and 11 require a connected window's controls to reach +// its actual engine agent with the computer, effort and recovery task intact. +func TestConnectedTaskComputerControlsReachTheEngineAgent(t *testing.T) { + taskComputerWireHome(t) + far := &pairedTaskWireAgent{fakeAgent: &fakeAgent{}, available: true} + file := t.TempDir() + "/chat.jsonl" + loop, err := Loopback(Hello{Version: Version}, Options{Boot: func(Hello) (*Engine, error) { + return &Engine{Agent: far, SessionFile: file}, nil + }}) + if err != nil { + t.Fatal(err) + } + defer loop.Close() + agent := loop.Client.Agent() + controls, ok := any(agent).(interface { + TaskComputersSupported() bool + StartTaskOn(context.Context, string, bool, string, ...string) (uint64, string, string, error) + RunTaskHere(context.Context, uint64) (string, error) + }) + if !ok || !controls.TaskComputersSupported() { + t.Fatal("the connection omits paired task controls") + } + id, title, note, err := controls.StartTaskOn(context.Background(), "repair export", true, "Abir's Mac", "cheap") + if err != nil || id != 7 || title != "Repair export" || note != "the task works in its own copy" { + t.Fatalf("named start = %d %q %q %v", id, title, note, err) + } + far.computerMu.Lock() + if far.computer != "Abir's Mac" || far.effort != "cheap" || far.brief != "repair export" || !far.solo { + t.Errorf("the start lost its chosen controls") + } + far.computerMu.Unlock() + if line, err := controls.RunTaskHere(context.Background(), 7); err != nil || line != "running task 7 Repair export here" { + t.Fatalf("run here = %q, %v", line, err) + } + far.computerMu.Lock() + if far.here != 7 { + t.Errorf("recovery reached task %d", far.here) + } + far.computerMu.Unlock() + agent.ResolveTask(7, session.TaskAnswer{Approved: true, Computer: "this computer"}) + waitFor(t, "the proposal's computer to cross", func() bool { + far.computerMu.Lock() + defer far.computerMu.Unlock() + return far.answer.Approved && far.answer.Computer == "this computer" + }) +} + +// The compatibility law keeps old requests byte-for-byte unchanged when no +// new optional choice is supplied; the original method names keep their work. +func TestExistingTaskRequestsKeepTheirMethodAndBytes(t *testing.T) { + taskComputerWireHome(t) + for _, tc := range []struct { + value any + want string + }{ + {TaskStartArgs{Brief: "repair export", Solo: true, Effort: "best"}, `{"brief":"repair export","solo":true,"effort":"best"}`}, + {TaskResolveArgs{ID: 7, Approved: true, Redirect: "also test it", Model: "test/model"}, `{"id":7,"approved":true,"redirect":"also test it","model":"test/model"}`}, + {session.TaskAnswer{Approved: true, Redirect: "also test it", Model: "test/model"}, `{"Approved":true,"Redirect":"also test it","Model":"test/model"}`}, + } { + raw, err := json.Marshal(tc.value) + if err != nil || string(raw) != tc.want { + t.Fatalf("old request changed: %s, %v; want %s", raw, err, tc.want) + } + } + if MethodTaskStart != "Task.Start" || MethodTaskResolve != "Task.Resolve" { + t.Fatal("an existing method was renamed") + } + far := &fakeAgent{} + loop, err := Loopback(Hello{Version: Version}, Options{Boot: func(Hello) (*Engine, error) { + return &Engine{Agent: far, SessionFile: t.TempDir() + "/chat.jsonl"}, nil + }}) + if err != nil { + t.Fatal(err) + } + defer loop.Close() + if _, _, _, err := loop.Client.Agent().StartTask(context.Background(), "repair export", true); err != nil { + t.Fatal(err) + } + far.mu.Lock() + defer far.mu.Unlock() + if len(far.tasks) != 1 || far.tasks[0] != "solo:repair export" { + t.Fatalf("the original start no longer reaches its original operation: %v", far.tasks) + } +} + +// The absence and compatibility laws require an omitted welcome capability +// to keep the new methods unavailable without probing an older engine. +func TestAnEngineWithoutPairedTaskMethodsAdvertisesNoComputerControls(t *testing.T) { + taskComputerWireHome(t) + client, old := newEngine(t) + controls, ok := any(client.Agent()).(interface{ TaskComputersSupported() bool }) + if !ok { + t.Fatal("the client cannot read the task-computer capability") + } + if controls.TaskComputersSupported() { + t.Fatal("an older welcome enabled paired task controls") + } + if _, err := client.Ping(); err != nil { + t.Fatal(err) + } + old.mu.Lock() + defer old.mu.Unlock() + for _, call := range old.seen { + if call.Method == "Task.StartOn" || call.Method == "Task.RunHere" { + t.Fatalf("an older engine was probed with %s", call.Method) + } + } +} + +// DESIGN contract 11 addresses the task in its own chat. Neither a foreign +// conversation claim nor a read-only connection may restart another task. +func TestConnectedTaskComputerControlsKeepTheirConversationAndRefuseWatchers(t *testing.T) { + for _, watch := range []bool{false, true} { + t.Run(map[bool]string{false: "owner", true: "watcher"}[watch], func(t *testing.T) { + taskComputerWireHome(t) + far := &pairedTaskWireAgent{fakeAgent: &fakeAgent{}, available: true} + file := t.TempDir() + "/chat.jsonl" + loop, err := Loopback(Hello{Version: Version, Watch: watch}, Options{Boot: func(Hello) (*Engine, error) { return &Engine{Agent: far, SessionFile: file}, nil }}) + if err != nil { + t.Fatal(err) + } + defer loop.Close() + claimed := t.TempDir() + "/another-chat.jsonl" + if watch { + claimed = file + } + if _, err := loop.Client.call(nil, MethodTaskStartOn, TaskStartOnArgs{Brief: "repair export", Computer: "work computer", Session: claimed}); err == nil { + t.Fatal("a foreign or read-only named start was accepted") + } + if _, err := loop.Client.call(nil, MethodTaskRunHere, TaskSetupArgs{ID: 7, Session: claimed}); err == nil { + t.Fatal("a foreign or read-only local restart was accepted") + } + far.computerMu.Lock() + defer far.computerMu.Unlock() + if far.computer != "" || far.here != 0 { + t.Fatal("a refused control changed work") + } + if watch && loop.Client.Agent().TaskComputersSupported() { + t.Fatal("a read-only window advertises paired task controls") + } + }) + } +} + +// The absence law applies to a new engine too: method shapes alone do not +// make an unpaired or ownerless conversation able to run tasks elsewhere. +func TestAnUnpairedOrOwnerlessEngineOffersNoTaskComputerControls(t *testing.T) { + for _, available := range []bool{false, true} { + t.Run(map[bool]string{false: "unpaired", true: "ownerless"}[available], func(t *testing.T) { + taskComputerWireHome(t) + far := &pairedTaskWireAgent{fakeAgent: &fakeAgent{}, available: available} + file := t.TempDir() + "/chat.jsonl" + if available { + file = "" + } + loop, err := Loopback(Hello{Version: Version}, Options{Boot: func(Hello) (*Engine, error) { return &Engine{Agent: far, SessionFile: file}, nil }}) + if err != nil { + t.Fatal(err) + } + defer loop.Close() + if loop.Client.Agent().TaskComputersSupported() { + t.Fatal("unusable paired task controls were advertised") + } + raw, err := json.Marshal(loop.Client.Welcome()) + if err != nil || strings.Contains(string(raw), `"taskComputers"`) { + t.Fatalf("the absent optional capability changed welcome bytes: %s %v", raw, err) + } + }) + } +} diff --git a/internal/remote/tasklane.go b/internal/remote/tasklane.go index 569903d85f..a3b855e92c 100644 --- a/internal/remote/tasklane.go +++ b/internal/remote/tasklane.go @@ -395,6 +395,7 @@ func (a *Agent) TaskUpdates() <-chan session.Event { func (a *Agent) ResolveTask(id uint64, answer session.TaskAnswer) { args := TaskResolveArgs{ ID: id, Approved: answer.Approved, Redirect: answer.Redirect, Model: answer.Model, + Computer: answer.Computer, } c := a.c go func() { diff --git a/internal/remote/wire.go b/internal/remote/wire.go index cc2709d6d0..7189a1b6af 100644 --- a/internal/remote/wire.go +++ b/internal/remote/wire.go @@ -1194,6 +1194,9 @@ type Welcome struct { TaskRetry bool `json:"taskRetry,omitempty"` TaskWorkspace bool `json:"taskWorkspace,omitempty"` TaskSetup bool `json:"taskSetup,omitempty"` + // TaskComputers enables named starts, proposal choices and local recovery. + // Absence keeps these controls off a window attached to an older engine. + TaskComputers bool `json:"taskComputers,omitempty"` // TaskSettle says this engine can be ASKED TO DECIDE A LANDING — accept, not // right, one more merge round, the hand-over and the take-back // ([MethodTaskSettle]). diff --git a/internal/remote/wire_task.go b/internal/remote/wire_task.go index 160d6897b8..3fdaf51064 100644 --- a/internal/remote/wire_task.go +++ b/internal/remote/wire_task.go @@ -18,6 +18,10 @@ const ( MethodDelegateList = "Delegate.List" MethodDelegateStart = "Delegate.Start" MethodTaskStart = "Task.Start" + // Placement uses new methods so an older Task.Start never silently drops + // a computer choice. The optional welcome capability gates both controls. + MethodTaskStartOn = "Task.StartOn" + MethodTaskRunHere = "Task.RunHere" // MethodTaskRedoStronger runs the newest task again with every seat nobody // pinned one step stronger ([session.Agent.RedoStronger]). MethodTaskRedoStronger = "Task.RedoStronger" @@ -175,6 +179,16 @@ type TaskStartArgs struct { Effort string `json:"effort,omitempty"` } +// TaskStartOnArgs belongs only to the new named-computer method. The older +// TaskStartArgs and Task.Start request keep their bytes and meaning unchanged. +type TaskStartOnArgs struct { + Brief string `json:"brief"` + Solo bool `json:"solo,omitempty"` + Computer string `json:"computer"` + Effort string `json:"effort,omitempty"` + Session string `json:"session,omitempty"` +} + // TaskRedoArgs names the task to run again stronger; 0 is the newest one. type TaskRedoArgs struct { ID uint64 `json:"id,omitempty"` @@ -216,15 +230,14 @@ type TaskPending struct { IDs []uint64 `json:"ids"` } -// TaskResolveArgs is one person's answer to one proposal, in the engine's own -// three fields ([session.TaskAnswer]): whether it may run, the correction to -// append if they redirected it, and the model they picked where the card -// offered a choice. +// TaskResolveArgs carries one person's proposal answer. The optional computer +// choice leaves the bytes of earlier answers unchanged when it is absent. type TaskResolveArgs struct { ID uint64 `json:"id"` Approved bool `json:"approved,omitempty"` Redirect string `json:"redirect,omitempty"` Model string `json:"model,omitempty"` + Computer string `json:"computer,omitempty"` } // TaskHoldArgs names the proposal whose first typed rune stopped its clock. From 8c294bc7f24d6c123a1255b2a210b0af7b04dc4d Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Fri, 30 Oct 2026 19:34:38 -0400 Subject: [PATCH 10/34] tui3: say where a task runs, choose the computer, and run it here /task --on names where a task runs, alone or with --best or --cheap; the proposal card shows where it will run and one key cycles the computer before it is accepted; the rail, Tasks rows, page and saved card say running on, ran on, stopped answering or interrupted on that computer beside the unchanged state word, with run it here where it applies; and settings gain a Run tasks on row. A conversation with no paired computers draws none of it. Co-Authored-By: Claude Opus 5.5 --- cmd/codeaf/chatv3_surface.go | 3 + internal/tui3/app.go | 7 +- internal/tui3/input.go | 10 +- internal/tui3/place_sessions.go | 3 +- internal/tui3/place_settings.go | 4 +- internal/tui3/room.go | 22 +- internal/tui3/settings.go | 42 ++- internal/tui3/settings_choice.go | 4 + internal/tui3/task.go | 92 ++++-- internal/tui3/taskcommand.go | 91 +++++- internal/tui3/taskcomputer.go | 254 +++++++++++++++ internal/tui3/taskcomputer_test.go | 478 +++++++++++++++++++++++++++++ internal/tui3/taskmention.go | 1 + internal/tui3/taskpane.go | 5 + internal/tui3/taskrecord.go | 23 +- internal/tui3/taskretry.go | 6 + internal/tui3/tasksplace.go | 20 +- internal/tui3/taskstatus.go | 2 +- internal/tui3/tui3.go | 3 + 19 files changed, 1013 insertions(+), 57 deletions(-) create mode 100644 internal/tui3/taskcomputer.go create mode 100644 internal/tui3/taskcomputer_test.go diff --git a/cmd/codeaf/chatv3_surface.go b/cmd/codeaf/chatv3_surface.go index d07066b077..608d80cce4 100644 --- a/cmd/codeaf/chatv3_surface.go +++ b/cmd/codeaf/chatv3_surface.go @@ -82,6 +82,9 @@ func runSurface(ctx context.Context, options tui3.Options) error { options.Restart = restart } options.CellsUnavailable = cell.UnavailableReason() + if options.TaskComputers == nil && options.Host == "" { + options.TaskComputers = v3TaskComputers(options.ProfileDir) + } if options.CellsUnavailable == "" && options.Host == "" { wireSync(&options) options.WaitForRelease = func() bool { diff --git a/internal/tui3/app.go b/internal/tui3/app.go index 20dda15fe0..1f3d4547b6 100644 --- a/internal/tui3/app.go +++ b/internal/tui3/app.go @@ -886,6 +886,10 @@ type ( ) type app struct { + taskComputerHere string + taskHerePending tasksKey + taskComputerRows []session.TaskComputer + taskComputers session.TaskComputers waitForRelease func() bool chatStarts map[string]bool conversationRequest uint64 @@ -3095,6 +3099,7 @@ func newApp(ctx context.Context, opts Options) *app { pairing: opts.Pairing, cellsUnavailable: opts.CellsUnavailable, approvals: opts.Approvals, + taskComputers: opts.TaskComputers, fleet: opts.Fleet, branches: opts.Branches, ctx: ctx, @@ -3625,7 +3630,7 @@ func (a *app) Init() tea.Cmd { // raised, and on the settings page — and reading it on the way up means // neither of those ever pays a round trip. The fold re-stamps whatever // questions the attach already replayed. - a.readAutonomy(), + a.readAutonomy(), a.readTaskComputers(), // AND THE PULSE'S OWN BEAT, whose first reading is taken now rather than // ten seconds from now (pulsebeat.go). pulseNow, diff --git a/internal/tui3/input.go b/internal/tui3/input.go index 74f492e4b5..a2f217ea7e 100644 --- a/internal/tui3/input.go +++ b/internal/tui3/input.go @@ -473,10 +473,9 @@ func (a *app) key(msg tea.KeyPressMsg) tea.Cmd { return tea.Batch(cmd, a.takeRoomPump()) } - // AND THE ONE KEY THE PROPOSAL STILL OWNS, which is not an answer: ctrl+e - // unfolds the assignment in the transcript (task.go). It is below the block - // because the block is where the proposal is answered and a key it draws - // must reach it first. + // The proposal's own keys disclose its brief or choose its computer without + // answering it (task.go). The question block comes first because a key it + // draws must reach the place where the proposal is answered. if cmd, taken := a.taskKey(msg); taken { return cmd } @@ -1041,8 +1040,7 @@ func (a *app) key(msg tea.KeyPressMsg) tea.Cmd { case "ctrl+,": // The settings key every application on this machine already has. The // slash is the other door onto the same panel (settings.go). - a.openSettings() - return nil + return a.openSettings() case effortKey: // WALK THE THINKING LADDER (effortchip.go). It is bound here, in the plain diff --git a/internal/tui3/place_sessions.go b/internal/tui3/place_sessions.go index 6a23275f5b..f1fff97a20 100644 --- a/internal/tui3/place_sessions.go +++ b/internal/tui3/place_sessions.go @@ -484,7 +484,7 @@ const taskSheetDays = 14 // that could ask it at paint time. func (a *app) taskSheetMine() tasksMine { rows := a.taskSheetOwnRows() - mine := tasksMine{row: a.taskSheetSelfRow(), tilde: a.tilde, rows: make([]tasksMineRow, 0, len(rows))} + mine := tasksMine{row: a.taskSheetSelfRow(), tilde: a.tilde, rows: make([]tasksMineRow, 0, len(rows)), taskComputers: a.taskComputersAvailable()} for _, entry := range rows { row := tasksMineRow{entry: entry, runs: a.recordRuns(&entry)} if node := a.taskSheetNodeFor(&entry); node != nil { @@ -600,6 +600,7 @@ func (a *app) taskSheetOwnRows() []session.TaskIndexEntry { label = node.title } rows = append(rows, session.TaskIndexEntry{ + Computer: node.computer, Unreachable: node.unreachable, RanElsewhere: node.ranElsewhere, ID: strconv.FormatUint(node.id, 10), Parent: node.parent, Label: label, diff --git a/internal/tui3/place_settings.go b/internal/tui3/place_settings.go index 6ad2a187e6..d820df95c8 100644 --- a/internal/tui3/place_settings.go +++ b/internal/tui3/place_settings.go @@ -42,8 +42,8 @@ func (placeSettings) open(a *app) tea.Cmd { // bar counting while it was up ([placeSettings.tick]). // // OVER --host THE TEAMS TAB READS THE FAR MACHINE, once, off the loop. A - // local launch asks for nothing. - return tea.Batch(a.armPlaceClock(), a.readHostTeamDefaults(), a.ensureSheetDevices()) + // local launch reads its paired task computers through the same off-loop road. + return tea.Batch(a.armPlaceClock(), a.readHostTeamDefaults(), a.ensureSheetDevices(), a.readTaskComputers()) } // tick re-reads nothing and keeps the beat: see the note over [placeSettings.open]. diff --git a/internal/tui3/room.go b/internal/tui3/room.go index 3dca49a100..3f5caae1cf 100644 --- a/internal/tui3/room.go +++ b/internal/tui3/room.go @@ -2112,6 +2112,9 @@ func (a *app) roomKey(msg tea.KeyPressMsg) (tea.Cmd, bool) { return a.slash(line), true } } + if msg.String() == taskRunHereKey && a.input.empty() && !a.guarding() && !a.stopping() && !a.recalling() && !a.roomIsGuest() && a.taskCanRunHere(a.roomRetryEntry()) { + return a.runTaskHere(a.roomRetryEntry()), true + } if a.room.orch != nil && !a.recalling() { if cmd, taken := a.orchKey(msg); taken { return cmd, true @@ -2248,6 +2251,9 @@ func (a *app) roomKey(msg tea.KeyPressMsg) (tea.Cmd, bool) { // next keystroke. func (a *app) roomHint() string { if a.room != nil && !a.roomIsGuest() && a.room.program == nil && !a.guarding() && !a.asking() && !a.stopping() { + if a.input.empty() && !a.recalling() && a.taskCanRunHere(a.roomRetryEntry()) { + return taskRunHereVerb + } entry := a.roomRetryEntry() if hint := a.taskRetryHint(entry); hint != "" { return hint @@ -3435,6 +3441,18 @@ func guestOwnerName(owner string) string { // roomStateWord is what the node is doing, in the engine's own vocabulary where // it has one (task.go's merge words). func (a *app) roomStateWord(node *taskNode) string { + word := a.roomLocalStateWord(node) + if words := a.taskComputerWords(node); words != "" { + if word == "" || strings.HasPrefix(words, word+" ") { + return words + } + return word + railSep + words + } + return word +} + +// The state reading stays the same when a separate placement fact is drawn. +func (a *app) roomLocalStateWord(node *taskNode) string { // A QUESTION SOMEBODY ELSE IS ANSWERING IS STILL THAT QUESTION. This line // used to read `awaiting review` for a card the settle policy had handed to // the model, which was a fourth word for one reading and said nothing about @@ -3469,8 +3487,8 @@ func (a *app) roomStateWord(node *taskNode) string { // IS at this moment (session's TaskNotice.Doing) — a header that said // "working" over a card waiting on the person would be the one line on the // page spent on the least specific thing on it. - if node.doing != "" { - return node.doing + if doing := taskDoingWords(node); doing != "" { + return doing } // AND SO DOES A NODE THAT IS NOT ITS OWN WORKER RIGHT NOW. A check // reading what the work left, and a round closing what the check found, diff --git a/internal/tui3/settings.go b/internal/tui3/settings.go index 60ef5f94c7..02fd46ccc5 100644 --- a/internal/tui3/settings.go +++ b/internal/tui3/settings.go @@ -321,6 +321,12 @@ var settingUI = map[string]settingMeta{ about: "the model a task's worker runs on when you have not asked for another. " + "Blank leaves it to the crew: your /crew pin, or the model picked for each task.", }, + // Placement sits beside the worker model because both describe the work + // handed out from this conversation rather than the chat's own calls. + config.KeyTaskRunsOn: { + tab: tabTasks, label: "Run tasks on", widget: widgetCycle, + about: taskComputerHint, + }, // THE CREW'S THREE SEATS ARE REGISTRY ROWS AND ARE NOT DRAWN AS THREE ROWS. // Each is empty for AUTO — codeaf routes that seat per task — and a model id // written there is a PIN, the same pin `/crew pin` writes (internal/config's @@ -858,7 +864,7 @@ func (s *sheet) finishCategory() { } // Lead with the controls a developer commonly changes, not registry order. if title == tabTasks { - priority := map[string]int{config.KeyTaskParallel: 0, config.KeyTaskModel: 1, config.KeyTaskAudit: 2, config.KeyTaskSettle: 3, config.KeyTaskRepairRounds: 4} + priority := map[string]int{config.KeyTaskParallel: 0, config.KeyTaskModel: 1, config.KeyTaskRunsOn: 2, config.KeyTaskAudit: 3, config.KeyTaskSettle: 4, config.KeyTaskRepairRounds: 5} rank := func(item sheetItem) int { if n, ok := priority[item.row.Key]; ok { return n @@ -952,8 +958,10 @@ func (i sheetItem) restful() bool { return i.head == "" && i.read == nil } // sheet is the panel's whole state. The zero value is closed and costs the // frame nothing. type sheet struct { - advanced map[string]bool - tab int + taskComputers bool + taskComputerRows []session.TaskComputer + advanced map[string]bool + tab int // host is the machine the session runs on over --host, "" otherwise. host string // teamDefaultsWrite says the teams seam can change that machine's `teams.` @@ -1281,7 +1289,7 @@ func (a *app) slotRefusal() error { // openSettings is /settings and ctrl+,, and it is THE ROUTER'S DOOR like every // other way into a place (pages.go's [app.showPage]). -func (a *app) openSettings() { a.showPage(pageSettings) } +func (a *app) openSettings() tea.Cmd { return a.showPage(pageSettings) } // raiseSettings builds the panel. It is [placeSettings]'s `open` and nothing // else calls it, which is what makes the router the one road in. @@ -1300,6 +1308,8 @@ func (a *app) raiseSettings() { write = true } a.sheet = sheet{ + taskComputers: a.taskComputersAvailable(), + taskComputerRows: a.taskComputerRows, host: a.host, teamDefaultsWrite: write, registry: a.registry(), @@ -1581,7 +1591,7 @@ func (s *sheet) build() { if len(s.items) == start { s.items = append(s.items, sheetItem{head: title}) } - s.items = append(s.items, sheetItem{row: one.row, meta: one.meta, hitAt: one.hitAt, hitLen: one.hitLen}) + s.items = append(s.items, sheetItem{row: s.taskComputerSetting(one.row), meta: one.meta, hitAt: one.hitAt, hitLen: one.hitLen}) } // A ROLE IS FOUND BY ITS OWN NAME, or by the line that says what it does. // Somebody searching for "planner" is not searching for a registry key — @@ -1655,6 +1665,9 @@ func (s *sheet) build() { // sheet rebuilds after every write, so the selected search row follows keys and // pins immediately while ordinary frame rendering stays read-only. func (s *sheet) metaFor(row config.Setting) (settingMeta, bool) { + if row.Key == config.KeyTaskRunsOn && !s.taskComputers { + return settingMeta{}, false + } meta, ok := settingMetaFor(row) if ok && row.Key == config.KeySearchProvider { meta.about = config.SearchProviderHintAt(s.profileDir) @@ -1681,8 +1694,8 @@ func (s *sheet) tabRows() []config.Setting { title := settingTabs[s.tab] mine := make([]config.Setting, 0, len(s.rows)) for _, row := range s.rows { - if meta, ok := settingMetaFor(row); ok && meta.tab == title { - mine = append(mine, row) + if meta, ok := s.metaFor(row); ok && meta.tab == title { + mine = append(mine, s.taskComputerSetting(row)) } } led := map[string]bool{} @@ -1770,7 +1783,7 @@ func (s *sheet) settingMatch(row config.Setting, meta settingMeta, tab string, t fields[4] = tab presentation := row.ChatPresentation() fields = append(fields, presentation.Aliases...) - fields = append(fields, settingValueWord(row)) + fields = append(fields, s.settingValueWord(row)) if old, ok := settingUI[row.Key]; ok { fields = append(fields, old.label, old.tab) } @@ -2620,6 +2633,12 @@ func (a *app) activate() tea.Cmd { return nil } choice := &sheetChoice{item: item, current: item.row.Value()} + if item.row.Key == config.KeyTaskRunsOn { + choice.current = config.TaskRunsOnAt(s.profileDir) + if taskComputerName(s.taskComputerRows, choice.current) == config.TaskRunsHere { + choice.current = "" + } + } for i, value := range item.row.Choices { if value == choice.current { choice.cursor = i @@ -2822,6 +2841,11 @@ func (a *app) applySetting(item sheetItem, raw string) bool { // banked rule through, and it rebuilds the policy from these same three rows. note := "" switch item.row.Key { + case config.KeyTaskRunsOn: + if a.task != nil { + a.task.computerPreference = config.TaskRunsOnAt(a.profileDir) + a.markCardStale(a.task) + } case config.KeyUpdateAuto: // THE LIVE SESSION SEES THE ROW IT JUST CHANGED. The grace re-reads // this flag, so turning it off in the panel must reach the running @@ -3773,7 +3797,7 @@ func (s *sheet) rowLinesWithin(item sheetItem, selected, hovered bool, width, bo // The unit is the registry's ([config.Setting.Unit]), so it is stated once, // beside the default, rather than spelled again by every surface that draws // a number ([config.Setting.Reading]). - value := settingValueWord(item.row) + value := s.settingValueWord(item.row) if hosted, ok := s.hostedTeamReading(item); ok { value = hosted diff --git a/internal/tui3/settings_choice.go b/internal/tui3/settings_choice.go index 1bd126336a..194ff16088 100644 --- a/internal/tui3/settings_choice.go +++ b/internal/tui3/settings_choice.go @@ -2,6 +2,7 @@ package tui3 import ( tea "charm.land/bubbletea/v2" + "github.com/Agent-Field/codeaf/internal/config" ) // sheetChoice keeps navigation separate from the persisted value. Opening or @@ -80,6 +81,9 @@ func (s *sheet) choiceRows(width, room int, pal palette, hovered int) []placeRow for i := top; i < top+count; i++ { raw := c.item.row.Choices[i] label := settingChoiceWord(c.item.row.Key, raw, raw) + if c.item.row.Key == config.KeyTaskRunsOn { + label = taskComputerName(s.taskComputerRows, raw) + } current := "" if raw == c.current { current = " · current" diff --git a/internal/tui3/task.go b/internal/tui3/task.go index 0af71b0ca2..aa3f20c388 100644 --- a/internal/tui3/task.go +++ b/internal/tui3/task.go @@ -55,6 +55,9 @@ import ( // [app.task], the lane that answers it — so the answer and the row can never // disagree about what was decided. type taskCard struct { + computerPicked bool + computerPreference string + computer string id uint64 title, summary, brief, acceptance, where string // name is the two-or-three-word NAME cut from the title, sub the one-line @@ -150,6 +153,9 @@ func (c *taskCard) settled() bool { return c.verdict != "" } // elapsed clock counts on the frame tick instead of freezing at whatever the // last update happened to say. Everything else is the notice, kept. type taskNode struct { + computer string + unreachable, ranElsewhere bool + remote *session.TaskRemoteRecord // workTurn records the request that admitted this live work, independently of completion time. workTurn int workOwned bool @@ -905,6 +911,7 @@ func (a *app) taskEvent(ev session.Event) tea.Cmd { a.note(ev.Text) case session.EventTaskProposal: a.proposeTask(ev) + pilot = a.readTaskComputers() case session.EventTaskUpdate: pilot = a.taskUpdate(ev) mentions = a.refreshTasks() @@ -1290,22 +1297,24 @@ func (a *app) proposeTask(ev session.Event) { brief := strings.TrimSpace(notice.Brief) name := taskTitleOf(title, firstNonEmpty(summary, brief), notice.ID) card := &taskCard{ - id: notice.ID, - title: title, - summary: summary, - brief: brief, - acceptance: strings.TrimSpace(notice.Acceptance), - where: strings.TrimSpace(notice.Where), - name: name, - sub: taskSubtitleOf(name, firstNonEmpty(summary, brief)), - ident: identFor(notice.ID), - dependsOn: notice.DependsOn, - model: strings.TrimSpace(notice.Model), - program: strings.TrimSpace(notice.Program), - ceiling: strings.TrimSpace(notice.Ceiling), - elsewhere: strings.TrimSpace(notice.Elsewhere), - deadline: notice.Deadline, - born: a.now(), + id: notice.ID, + computer: strings.TrimSpace(notice.Computer), + computerPreference: config.TaskRunsOnAt(a.profileDir), + title: title, + summary: summary, + brief: brief, + acceptance: strings.TrimSpace(notice.Acceptance), + where: strings.TrimSpace(notice.Where), + name: name, + sub: taskSubtitleOf(name, firstNonEmpty(summary, brief)), + ident: identFor(notice.ID), + dependsOn: notice.DependsOn, + model: strings.TrimSpace(notice.Model), + program: strings.TrimSpace(notice.Program), + ceiling: strings.TrimSpace(notice.Ceiling), + elsewhere: strings.TrimSpace(notice.Elsewhere), + deadline: notice.Deadline, + born: a.now(), } a.task = card a.closeLive() @@ -1593,11 +1602,12 @@ const ( // something is being asked, which is what its readers actually want to know. func (a *app) awaitingTask() bool { return a.task != nil && !a.task.settled() } -// taskKey is the ONE key the proposal still owns, and it is not an answer. +// taskKey handles the proposal's disclosure and computer keys. Neither answers +// the shared question, whose own keys still decide whether the work starts. // -// The three answers and the clock went to the question block with every other -// decision on this surface (question.go), and what is left here is the key that -// opens the assignment in the transcript: `ctrl+e` is what this surface means +// The answers and clock belong to the shared question. These keys change what +// its assignment shows: `ctrl+d` cycles eligible computers before acceptance, +// while `ctrl+e` is what this surface means // "show me the rest of this" by, and the brief is the rest of a proposal. It // belongs to the lane rather than to the block because it acts on the BLOCK IN // THE TRANSCRIPT — the question above the box has nothing to unfold. @@ -1606,12 +1616,22 @@ func (a *app) awaitingTask() bool { return a.task != nil && !a.task.settled() } // is the rule input.go applies to the thinking block, and it is applied here for // the same reason: ctrl+e is end-of-line in every shell a person has used. func (a *app) taskKey(msg tea.KeyPressMsg) (tea.Cmd, bool) { - if !a.awaitingTask() || msg.String() != "ctrl+e" { + if !a.awaitingTask() { return nil, false } if !a.input.empty() { return nil, false } + if msg.String() == taskComputerCycleKey { + if a.questionOffFrame() || !a.canCycleTaskComputer(a.task) { + return nil, false + } + a.cycleTaskComputer() + return nil, true + } + if msg.String() != "ctrl+e" { + return nil, false + } a.toggleCard() return nil, true } @@ -1644,6 +1664,12 @@ func (a *app) taskAnswered(id uint64, answer session.Answer) session.Answer { } card.verdict = taskVerdict(approve, redirect) card.answer = word + if approve && card.computerPicked && a.taskComputersAvailable() { + if answer.Blanks == nil { + answer.Blanks = map[string]string{} + } + answer.Blanks[session.TaskComputerBlank] = card.computer + } // A YES OPENS THE FORMING BLOCK. The task the card names does not exist // until its first update arrives, and the person who just said yes is owed // something moving in that pause rather than nothing (taskcommand.go's @@ -1961,6 +1987,9 @@ func (a *app) taskCardRows(card *taskCard, width int, sel bool) []string { if card.where != "" { out = append(out, stem+a.pal.dim(fit("where: "+card.where, room))) } + if computer := a.proposalComputerName(card); computer != "" { + out = append(out, stem+a.pal.dim(fit("runs on "+computer, room))) + } if point := a.taskBranchPoint(); point != "" && card.program == "" { // The branch point is the last of the facts about the work, and it is the // one thing on the card a person cannot find out afterwards without @@ -2197,6 +2226,9 @@ func (a *app) taskMetaWord(card *taskCard, width int) string { if card.model != "" { parts = append(parts, taskModelTag+card.model) } + if a.canCycleTaskComputer(card) { + parts = append(parts, taskComputerCycleHint) + } if !card.open && card.brief != "" { parts = append(parts, taskExpandHint) } @@ -4390,6 +4422,15 @@ func (a *app) railTitle(node *taskNode, title string) string { // handles back to work that is not on screen, and half of one of those is worth // nothing at all. func (a *app) railUnder(node *taskNode, width int) []string { + if words := a.taskComputerWords(node); words != "" { + rows := []string{a.pal.dim(fit(words, width))} + if node.state == session.TaskRunning { + if figures := a.railTelemetry(node, width); figures != "" { + rows = append(rows, a.pal.dim(figures)) + } + } + return rows + } paint, text := a.pal.dim, "" switch node.state { case session.TaskRunning: @@ -4760,10 +4801,13 @@ func planLiveRow(command string, parts []session.PlanCommandPart, width int, pal // is doing, in the only vocabulary it has — so a prefix would be the surface // explaining a plain English word with a second plain English word. func (a *app) railDoing(node *taskNode, width int) []string { - if node.doing == "" { + if words := a.taskComputerWords(node); words != "" { + return []string{a.pal.dim(fit(words, width))} + } + if taskDoingWords(node) == "" { return nil } - return []string{a.pal.dim(fit(node.doing, width))} + return []string{a.pal.dim(fit(taskDoingWords(node), width))} } // railProgramRow is the run's own plan row for a node whose run was handed to @@ -5291,6 +5335,8 @@ func (a *app) taskUpdate(ev session.Event) tea.Cmd { resumed = a.resumeRetriedRoom(node) } node.state = notice.State + node.computer, node.unreachable, node.ranElsewhere = notice.Computer, notice.Unreachable, notice.RanElsewhere + node.remote = notice.Remote if len(notice.DependsOn) > 0 { node.dependsOn = notice.DependsOn } diff --git a/internal/tui3/taskcommand.go b/internal/tui3/taskcommand.go index 7644a11dfa..755383713e 100644 --- a/internal/tui3/taskcommand.go +++ b/internal/tui3/taskcommand.go @@ -6,6 +6,7 @@ import ( "strconv" "strings" "time" + "unicode" tea "charm.land/bubbletea/v2" @@ -29,6 +30,50 @@ type taskCommandAgent interface { StartTask(context.Context, string, bool) (uint64, string, string, error) } +type taskCommandOnAgent interface { + StartTaskOn(context.Context, string, bool, string, ...string) (uint64, string, string, error) +} + +// Only the computer name is unquoted. The rest is the person's brief, whose +// punctuation and spacing must reach the worker without shell interpretation. +func taskComputerArgument(arg string) (name, rest string, ok bool) { + arg = strings.TrimSpace(arg) + if arg == "" { + return "", "", false + } + quote := arg[0] + if quote != '"' && quote != '\'' { + at := strings.IndexFunc(arg, unicode.IsSpace) + if at < 0 { + return arg, "", true + } + return arg[:at], strings.TrimSpace(arg[at:]), true + } + var word strings.Builder + for i := 1; i < len(arg); i++ { + if arg[i] == quote { + tail := arg[i+1:] + if tail != "" && strings.TrimLeftFunc(tail, unicode.IsSpace) == tail { + return "", "", false + } + return word.String(), strings.TrimSpace(tail), word.Len() > 0 + } + if arg[i] == '\\' && quote == '"' && i+1 < len(arg) && (arg[i+1] == quote || arg[i+1] == '\\') { + i++ + } + word.WriteByte(arg[i]) + } + return "", "", false +} + +func (a *app) taskCommandUsage() string { + usage := "usage: /task · /task solo · /task --best · /task --cheap " + if _, ok := a.agent.(taskCommandOnAgent); ok && a.taskComputersAvailable() { + usage += " · /task --on " + } + return usage +} + // taskCrewEffortAgent is the session's door for a task with a one-task effort // word said: best or cheap (session's taskcrew.go). It is a second interface // rather than a wider first one, so a surface or a test double that has only @@ -136,15 +181,29 @@ func (a *app) runTaskCommand(arg string) tea.Cmd { a.note("could not start the task · this session has no task door") return nil } - // THE WHOLE VOCABULARY IS TWO FORMS: a brief, or `solo` and a brief. A first - // word that is neither of those is simply the beginning of the brief, so the - // split is taken once here and the brief defaults to everything typed. - // HOW HARD TO TRY THIS ONE TASK comes first when it is said at all — - // `/task --best …`, `/task --cheap …` — and moves this task's crew and - // nothing after it (crew.go). A flag that is neither is part of the brief. - effort := "" - if flag, after, _ := strings.Cut(arg, " "); flag == "--best" || flag == "--cheap" { - effort, arg = strings.TrimPrefix(flag, "--"), strings.TrimSpace(after) + // Placement and effort are read only from the prefix, so the work's own + // words stay intact. Without the placement capability, --on remains part + // of the brief just as every unrecognized flag did before it existed. + effort, computer := "", "" + onDoor, canPlace := a.agent.(taskCommandOnAgent) + canPlace = canPlace && a.taskComputersAvailable() + for { + flag, after, _ := strings.Cut(arg, " ") + if effort == "" && (flag == "--best" || flag == "--cheap") { + effort, arg = strings.TrimPrefix(flag, "--"), strings.TrimSpace(after) + if canPlace { + continue + } + } else if canPlace && computer == "" && flag == "--on" { + var valid bool + computer, arg, valid = taskComputerArgument(after) + if !valid { + a.note(a.taskCommandUsage()) + return nil + } + continue + } + break } word, rest, _ := strings.Cut(arg, " ") rest = strings.TrimSpace(rest) @@ -172,9 +231,21 @@ func (a *app) runTaskCommand(arg string) tea.Cmd { } } if brief == "" { - a.note("usage: /task · /task solo · /task --best · /task --cheap ") + a.note(a.taskCommandUsage()) return nil } + if computer != "" { + solo = solo || config.TaskStartAt(a.profileDir) == config.TaskStartSingle + file := a.file + return a.startTaskDoorVia(brief, func(ctx context.Context) (uint64, string, string, error) { + if bound, ok := onDoor.(interface { + StartTaskOnIn(context.Context, string, bool, string, string, string) (uint64, string, string, error) + }); ok { + return bound.StartTaskOnIn(ctx, brief, solo, computer, effort, file) + } + return onDoor.StartTaskOn(ctx, brief, solo, computer, effort) + }) + } if effort != "" { door, ok := a.agent.(taskCrewEffortAgent) if !ok { diff --git a/internal/tui3/taskcomputer.go b/internal/tui3/taskcomputer.go new file mode 100644 index 0000000000..3f5860b0d7 --- /dev/null +++ b/internal/tui3/taskcomputer.go @@ -0,0 +1,254 @@ +package tui3 + +import ( + "context" + "strings" + + tea "charm.land/bubbletea/v2" + "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/session" +) + +const taskComputerHint = "where a task goes when you do not name a computer" + +const taskRunHereKey = "h" +const taskRunHereVerb = taskRunHereKey + " run it here" +const taskRunHereSuffix = " — run it here" +const taskComputerCycleKey = "ctrl+d" +const taskComputerCycleHint = taskComputerCycleKey + " computer" + +type taskRunHereAgent interface { + RunTaskHere(context.Context, uint64) (string, error) +} + +// A connection has the Go methods even when its engine predates them. Its +// welcome must explicitly allow these controls before a window draws them. +func (a *app) taskComputersAvailable() bool { + if a.taskComputers == nil { + return false + } + if connected, ok := a.agent.(interface{ TaskComputersSupported() bool }); ok { + return connected.TaskComputersSupported() + } + return true +} + +// These are the same applicability facts RunTaskHere checks. The owning live +// notice supplies them; an interrupted local row supplies none of them. +func (a *app) taskCanRunHere(entry session.TaskIndexEntry) bool { + if !a.taskComputersAvailable() || a.taskSheet.awayOwner.on || a.taskHerePending == tasksKeyOf(entry) { + return false + } + if _, ok := a.agent.(taskRunHereAgent); !ok { + return false + } + node := a.taskSheetNodeFor(&entry) + if node == nil || node.remote == nil || node.remote.Abandoned { + return false + } + r := node.remote + return r.StartFailed || r.Unreachable && !r.Finished || node.state == session.TaskInterrupted +} + +func (a *app) runTaskHere(entry session.TaskIndexEntry) tea.Cmd { + if !a.taskCanRunHere(entry) { + return nil + } + node := a.taskSheetNodeFor(&entry) + door := a.agent.(taskRunHereAgent) + ctx, id, key, file := a.ctx, node.id, tasksKeyOf(entry), a.file + a.taskHerePending = key + a.touch() + return a.offLoop(func() func(bool) tea.Cmd { + var words string + var err error + if bound, ok := door.(interface { + RunTaskHereIn(context.Context, uint64, string) (string, error) + }); ok { + words, err = bound.RunTaskHereIn(ctx, id, file) + } else { + words, err = door.RunTaskHere(ctx, id) + } + return func(here bool) tea.Cmd { + if a.taskHerePending == key { + a.taskHerePending = tasksKey{} + } + if here { + if err != nil { + a.note(err.Error()) + } else if words != "" { + a.note(words) + } + a.touch() + } + return nil + } + }) +} + +// The roster is read on entry, never while drawing. A failed refresh retains +// the last names rather than claiming a paired computer was removed. +func (a *app) readTaskComputers() tea.Cmd { + if !a.taskComputersAvailable() { + return nil + } + computers, ctx := a.taskComputers, a.ctx + return a.besideLine(func() func(bool) tea.Cmd { + rows, err := computers.List(ctx) + localName := computers.ThisComputer() + return func(here bool) tea.Cmd { + if !here || err != nil { + return nil + } + a.taskComputerRows = nil + a.taskComputerHere = localName + for _, row := range rows { + if row.CanTakeTasks { + a.taskComputerRows = append(a.taskComputerRows, row) + } + } + if a.sheet.registry != nil { + a.sheet.taskComputerRows = a.taskComputerRows + a.sheet.build() + } + if a.task != nil { + a.markCardStale(a.task) + } + a.touch() + return nil + } + }) +} + +func (a *app) proposalComputerName(card *taskCard) string { + if !a.taskComputersAvailable() { + return "" + } + named := strings.TrimSpace(card.computer) + if named == "" { + return taskComputerName(a.taskComputerRows, card.computerPreference) + } + if strings.EqualFold(named, "here") || strings.EqualFold(named, config.TaskRunsHere) || strings.EqualFold(named, a.taskComputerHere) { + return config.TaskRunsHere + } + for _, computer := range a.taskComputerRows { + if strings.EqualFold(named, computer.Name) { + return computer.Name + } + } + return named +} + +func (a *app) canCycleTaskComputer(card *taskCard) bool { + return card != nil && !card.forming && !card.settled() && a.taskComputersAvailable() && len(a.taskComputerRows) > 0 +} + +// A pick belongs to this proposal, not to the standing preference. The answer +// carries it only when the person later accepts or corrects the proposal. +func (a *app) cycleTaskComputer() { + card := a.task + if !a.canCycleTaskComputer(card) { + return + } + choices := []string{config.TaskRunsHere} + for _, computer := range a.taskComputerRows { + choices = append(choices, computer.Name) + } + current, at := a.proposalComputerName(card), -1 + for i, choice := range choices { + if strings.EqualFold(choice, current) { + at = i + break + } + } + a.holdTask(card.id) + card.computer, card.computerPicked = choices[(at+1)%len(choices)], true + a.markCardStale(card) + a.touch() +} + +func taskComputerName(rows []session.TaskComputer, device string) string { + for _, row := range rows { + if row.Device == device { + return row.Name + } + } + return config.TaskRunsHere +} + +func (s *sheet) taskComputerSetting(row config.Setting) config.Setting { + if row.Key == config.KeyTaskRunsOn { + row.Choices = []string{""} + row.Hint = taskComputerHint + for _, computer := range s.taskComputerRows { + row.Choices = append(row.Choices, computer.Device) + } + } + return row +} + +func (s *sheet) settingValueWord(row config.Setting) string { + if row.Key == config.KeyTaskRunsOn { + return taskComputerName(s.taskComputerRows, config.TaskRunsOnAt(s.profileDir)) + } + return settingValueWord(row) +} + +// Placement is a separate fact from the shared state reading. A checkpoint +// saying running alone never supplies a computer's identity. +func taskComputerWords(name string, unreachable, ran bool, state session.TaskState) string { + if name == "" { + return "" + } + if unreachable { + return session.TaskComputerStoppedAnswering(name) + } + if state == session.TaskInterrupted { + return "interrupted on " + name + } + if ran { + return session.TaskRanOn(name) + } + if state == session.TaskRunning || state == session.TaskQueued { + return session.TaskRunningOn(name) + } + return "" +} + +func (a *app) taskComputerWords(node *taskNode) string { + if !a.taskComputersAvailable() || node == nil { + return "" + } + if node.remote != nil && node.remote.Abandoned { + return "" + } + if node.remote != nil && node.remote.StartFailed && !node.remote.Abandoned { + return strings.TrimSuffix(firstNonEmpty(node.doing, node.report), taskRunHereSuffix) + } + return taskComputerWords(node.computer, node.unreachable, node.ranElsewhere, node.state) +} + +// The session carries placement on the phase lane too. It remains a placement fact, +// rather than becoming a second state word or being drawn twice in the room. +func taskDoingWords(node *taskNode) string { + if node.computer != "" { + switch node.doing { + case session.TaskRunningOn(node.computer), session.TaskRanOn(node.computer), session.TaskComputerStoppedAnswering(node.computer): + return "" + } + if node.remote != nil && node.remote.StartFailed { + return "" + } + } + return node.doing +} + +func (a *app) taskEntryComputerWords(entry session.TaskIndexEntry) string { + if !a.taskComputersAvailable() { + return "" + } + if node := a.taskSheetNodeFor(&entry); node != nil { + return a.taskComputerWords(node) + } + return taskComputerWords(entry.Computer, entry.Unreachable, entry.RanElsewhere, session.TaskState(entry.Status)) +} diff --git a/internal/tui3/taskcomputer_test.go b/internal/tui3/taskcomputer_test.go new file mode 100644 index 0000000000..c14c26fd8c --- /dev/null +++ b/internal/tui3/taskcomputer_test.go @@ -0,0 +1,478 @@ +package tui3 + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/session" +) + +const surfaceComputerB = "dev_11111111111111111111111111111111" +const surfaceComputerC = "dev_22222222222222222222222222222222" +const surfaceComputerWindows = "dev_33333333333333333333333333333333" +const surfaceComputerRemoved = "dev_44444444444444444444444444444444" + +type surfaceTaskComputers []session.TaskComputer + +func (c surfaceTaskComputers) List(context.Context) ([]session.TaskComputer, error) { return c, nil } +func (surfaceTaskComputers) ThisComputer() string { return "home computer" } +func (surfaceTaskComputers) Engine(context.Context, string) (session.TaskRunEngine, error) { + return nil, nil +} + +type surfaceTaskComputerAgent struct { + *taskFake + computerAnswers []session.Answer + here []uint64 + computer, brief string + effort string + solo bool + starts, localStarts int + startErr error + startNote string +} + +type olderTaskComputerSurface struct{ *surfaceTaskComputerAgent } + +func (*olderTaskComputerSurface) TaskComputersSupported() bool { return false } + +// The wire compatibility law requires the welcome's missing capability to +// hide the new controls even though the client now has their Go methods. +func TestAnOlderEngineHasNoTaskComputerFlagOrRecoveryVerb(t *testing.T) { + a, f, _ := computerRecoveryApp(t, session.TaskInterrupted, &session.TaskRemoteRecord{StartFailed: true}, "work computer is not taking tasks right now — run codeaf serve on it") + a.agent = &olderTaskComputerSurface{f} + if strings.Contains(a.taskCommandUsage(), "--on") { + t.Fatal("an older engine advertises --on") + } + rows, _, _, _ := a.taskCardFrame(120, 40) + if text := plain(strings.Join(rows, "\n")); strings.Contains(text, "run it here") { + t.Fatalf("an older engine advertises recovery:\n%s", text) + } + if cmd := a.taskCardKey("h"); cmd != nil || len(f.here) != 0 { + t.Fatal("an absent recovery control reached the engine") + } + a.taskSheet.detailOn = false + a.proposeTask(proposal(a, 8, 0)) + if text := plain(strings.Join(a.taskCardRows(a.task, 120, false), "\n")); strings.Contains(text, "ctrl+d computer") || strings.Contains(text, "runs on") { + t.Fatalf("an older engine advertises proposal placement:\n%s", text) + } + if cmd, took := a.taskKey(key("ctrl+d")); cmd != nil || took { + t.Fatal("an older engine takes the computer-cycle key") + } +} + +func (f *surfaceTaskComputerAgent) ResolveQuestion(answer session.Answer) error { + f.computerAnswers = append(f.computerAnswers, answer) + return f.taskFake.ResolveQuestion(answer) +} + +// DESIGN contracts 1 and 6 require both prefix orders to carry the same +// computer and one-task effort through the named placement operation. +func TestTaskOnCarriesEffortInEitherOrder(t *testing.T) { + for _, tc := range []struct{ arg, effort string }{ + {`--best --on "Abir's Mac" repair export`, "best"}, + {`--on "Abir's Mac" --best repair export`, "best"}, + {`--cheap --on "Abir's Mac" repair export`, "cheap"}, + {`--on "Abir's Mac" --cheap repair export`, "cheap"}, + } { + t.Run(tc.arg, func(t *testing.T) { + a, base, _ := taskApp(t) + f := &surfaceTaskComputerAgent{taskFake: base} + a.agent, a.taskComputers = f, surfaceTaskComputers{} + drive(t, a, taskMsg(a.slash("/task "+tc.arg))) + if f.starts != 1 || f.localStarts != 0 || f.computer != "Abir's Mac" || f.effort != tc.effort || f.brief != "repair export" { + t.Fatalf("computer or effort was lost: %+v", f) + } + }) + } +} + +// DESIGN contract 1 requires a proposal's explicit choice to win over its +// default. Choosing a computer answers nothing until the person accepts. +func TestTheProposalCardCyclesWhereTheTaskRuns(t *testing.T) { + a, base, _ := taskApp(t) + f := &surfaceTaskComputerAgent{taskFake: base} + f.pending = []uint64{7} + a.agent = f + a.taskComputers = surfaceTaskComputers{ + {Device: surfaceComputerB, Name: "work computer", CanTakeTasks: true}, + {Device: surfaceComputerC, Name: "Abir's Mac", CanTakeTasks: true}, + {Device: surfaceComputerWindows, Name: "Windows computer"}, + } + if err := config.WriteTaskRunsOn(a.profileDir, surfaceComputerB); err != nil { + t.Fatal(err) + } + drive(t, a, taskMsg(a.readTaskComputers())) + ev := proposal(a, 7, 0) + ev.Task.Model, ev.Task.ModelOptions = "test/one", []string{"test/one", "test/two"} + drive(t, a, streamEventMsg{gen: a.gen, ev: ev}) + taskText(a) + settleAsk(a) + text := plain(strings.Join(a.taskCardRows(a.task, 120, false), "\n")) + if !strings.Contains(text, "runs on work computer") || !strings.Contains(text, "ctrl+d computer") { + t.Fatalf("the proposal hides its choice or key:\n%s", text) + } + a.input.setText("keep my draft") + drive(t, a, key("ctrl+d")) + if a.input.String() != "keep my draft" || !strings.Contains(plain(strings.Join(a.taskCardRows(a.task, 120, false), "\n")), "runs on work computer") { + t.Fatal("the computer key changed a nonempty draft or its placement") + } + a.input.setText("") + drive(t, a, key("right")) + for _, want := range []string{"Abir's Mac", "this computer", "work computer", "Abir's Mac", "this computer"} { + drive(t, a, key("ctrl+d")) + if text := plain(strings.Join(a.taskCardRows(a.task, 120, false), "\n")); !strings.Contains(text, "runs on "+want) || strings.Contains(text, "Windows computer") { + t.Fatalf("the cycle did not pick %q:\n%s", want, text) + } + if len(f.computerAnswers) != 0 { + t.Fatal("cycling answered the proposal") + } + } + drive(t, a, key("1")) + if len(f.computerAnswers) != 1 { + t.Fatalf("proposal answers = %d", len(f.computerAnswers)) + } + answer := f.computerAnswers[0] + if answer.Blanks[session.TaskComputerBlank] != "this computer" || answer.Blanks[session.TaskModelBlank] != "test/two" { + t.Fatalf("the accepted choices were lost: %+v", answer) + } + if got := config.TaskRunsOnAt(a.profileDir); got != surfaceComputerB { + t.Fatalf("a one-task choice changed the standing default: %q", got) + } +} + +func (f *surfaceTaskComputerAgent) StartTaskEffort(ctx context.Context, brief string, solo bool, effort string) (uint64, string, string, error) { + f.effort = effort + return f.StartTask(ctx, brief, solo) +} + +// TestTaskComputerPlacementKeepsEveryExistingTaskForm covers SPEC-R4's +// compatibility requirement at the typed command boundary, including the +// workspace verbs that must be intercepted before any brief is started. +func TestTaskComputerPlacementKeepsEveryExistingTaskForm(t *testing.T) { + t.Setenv("CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP", "") + for _, tc := range []struct { + arg, brief, effort string + solo, intercepted bool + }{ + {arg: "repair export", brief: "repair export"}, + {arg: "solo repair export", brief: "repair export", solo: true}, + {arg: "--best repair export", brief: "repair export", effort: "best"}, + {arg: "--cheap solo repair export", brief: "repair export", effort: "cheap", solo: true}, + {arg: "adaptive rate limiting", brief: "adaptive rate limiting"}, + {arg: "--unknown repair export", brief: "--unknown repair export"}, + {arg: "review", intercepted: true}, + {arg: "apply", intercepted: true}, + } { + t.Run(tc.arg, func(t *testing.T) { + a, base, _ := taskApp(t) + f := &surfaceTaskComputerAgent{taskFake: base} + a.agent, a.taskComputers = f, surfaceTaskComputers{} + drive(t, a, taskMsg(a.slash("/task "+tc.arg))) + if tc.intercepted { + if f.starts != 0 || f.localStarts != 0 { + t.Fatal("a workspace verb was sent as a brief") + } + return + } + if f.starts != 0 || f.localStarts != 1 || f.brief != tc.brief || f.solo != tc.solo || f.effort != tc.effort { + t.Fatalf("an existing form changed: %+v", f) + } + }) + } +} + +func (f *surfaceTaskComputerAgent) StartTask(_ context.Context, brief string, solo bool) (uint64, string, string, error) { + f.localStarts++ + f.brief, f.solo = brief, solo + return 7, "Repair export", f.startNote, f.startErr +} + +func (f *surfaceTaskComputerAgent) StartTaskOn(_ context.Context, brief string, solo bool, computer string, effort ...string) (uint64, string, string, error) { + f.starts++ + f.brief, f.solo, f.computer = brief, solo, computer + if len(effort) > 0 { + f.effort = effort[0] + } + return 7, "Repair export", f.startNote, f.startErr +} + +// TestTaskOnNamesTheComputerTheTaskRunsOn maps to DESIGN contract 6 at the +// command boundary: the named computer reaches R3's existing placement door. +func TestTaskOnNamesTheComputerTheTaskRunsOn(t *testing.T) { + for _, tc := range []struct { + arg, computer, brief string + solo bool + }{ + {`--on work repair export`, "work", "repair export", false}, + {`--on "Abir's Mac" repair export`, "Abir's Mac", "repair export", false}, + {`--on here solo repair export`, "here", "repair export", true}, + } { + t.Run(tc.arg, func(t *testing.T) { + a, base, _ := taskApp(t) + f := &surfaceTaskComputerAgent{taskFake: base} + a.agent, a.taskComputers = f, surfaceTaskComputers{} + drive(t, a, taskMsg(a.slash("/task "+tc.arg))) + if f.starts != 1 || f.localStarts != 0 || f.computer != tc.computer || f.brief != tc.brief || f.solo != tc.solo { + t.Fatalf("the command did not reach placement: %+v", f) + } + }) + } +} + +// TestTaskOnWithAnUnknownNameListsTheComputers maps to DESIGN contract 6: +// the placement door's correctable error reaches the person unchanged. +func TestTaskOnWithAnUnknownNameListsTheComputers(t *testing.T) { + const problem = `no paired computer is named "missing" — your computers are: Abir's Mac, work computer` + a, base, _ := taskApp(t) + f := &surfaceTaskComputerAgent{taskFake: base, startErr: errors.New(problem)} + a.agent, a.taskComputers = f, surfaceTaskComputers{} + drive(t, a, taskMsg(a.slash("/task --on missing repair export"))) + if f.starts != 1 || !strings.Contains(lastNote(t, a), problem) { + t.Fatalf("the unknown computer's answer did not reach the person: %q", lastNote(t, a)) + } +} + +// TestTheProposalCardNamesItsExplicitOrDefaultComputer covers DESIGN 1.1's +// placement disclosure before approval, independent of the later run state. +func TestTheProposalCardNamesItsExplicitOrDefaultComputer(t *testing.T) { + for _, tc := range []struct{ computer, preference, shown string }{ + {"", "", "this computer"}, + {"", surfaceComputerB, "work computer"}, + {"here", surfaceComputerB, "this computer"}, + {"Abir's Mac", surfaceComputerB, "Abir's Mac"}, + } { + t.Run(tc.computer+tc.preference, func(t *testing.T) { + a, _, _ := taskApp(t) + a.taskComputers = surfaceTaskComputers{{Device: surfaceComputerB, Name: "work computer", CanTakeTasks: true}, {Device: surfaceComputerC, Name: "Abir's Mac", CanTakeTasks: true}} + if err := config.WriteTaskRunsOn(a.profileDir, tc.preference); err != nil { + t.Fatal(err) + } + drive(t, a, taskMsg(a.readTaskComputers())) + ev := proposal(a, 7, 0) + ev.Task.Computer = tc.computer + a.proposeTask(ev) + if text := plain(strings.Join(a.taskCardRows(a.task, 120, false), "\n")); !strings.Contains(text, "runs on "+tc.shown) { + t.Fatalf("the proposal does not say where it will run:\n%s", text) + } + }) + } +} + +// TestNothingAboutComputersIsDrawnWhenNothingIsPaired covers the absence law: +// nil TaskComputers leaves the old command grammar and local surface intact. +func TestNothingAboutComputersIsDrawnWhenNothingIsPaired(t *testing.T) { + a, base, _ := taskApp(t) + f := &surfaceTaskComputerAgent{taskFake: base} + a.agent = f + a.proposeTask(proposal(a, 7, 0)) + if text := plain(strings.Join(a.taskCardRows(a.task, 120, false), "\n")); strings.Contains(text, "runs on") || strings.Contains(text, "computer") { + t.Fatalf("an unpaired proposal draws placement:\n%s", text) + } + if cmd, took := a.taskKey(key("ctrl+d")); cmd != nil || took { + t.Fatal("an unpaired proposal takes the computer key") + } + // A saved remote record does not enable a capability this conversation lacks. + entry := session.TaskIndexEntry{ID: "9", Title: "Earlier export", Status: string(session.TaskDone), Computer: "work computer", RanElsewhere: true} + reading := readTasks(session.World{}, a.taskSheetMine(), session.LastDays(a.now(), 1), tasksSort{}, a.now(), a.now()) + line := tasksLine{kind: tasksLineTask, item: &tasksItem{entry: entry}} + if text := plain(reading.paint([]tasksLine{line}, 0, 160, a.pal, false)); strings.Contains(text, "work computer") { + t.Fatalf("an unavailable placement leaked from an old record: %s", text) + } + a.task.verdict = "approved" + drive(t, a, key("ctrl+,")) + for i := range settingTabs { + a.sheet.tab = i + a.sheet.build() + for _, item := range a.sheet.items { + if item.row.Key == config.KeyTaskRunsOn { + t.Fatal("an unpaired conversation offers Run tasks on") + } + } + } + a.sheet.query.setText("Run tasks on") + a.sheet.build() + for _, item := range a.sheet.items { + if item.row.Key == config.KeyTaskRunsOn { + t.Fatal("search reveals the unavailable computer setting") + } + } + a.dropSettings() + if strings.Contains(a.taskCommandUsage(), "--on") { + t.Fatal("the old command advertises a computer flag") + } + drive(t, a, taskMsg(a.runTaskCommand("--on work repair export"))) + if f.starts != 0 || f.localStarts != 1 || f.brief != "--on work repair export" { + t.Fatalf("the unavailable flag changed the old brief grammar: %+v", f) + } +} + +func (f *surfaceTaskComputerAgent) RunTaskHere(_ context.Context, id uint64) (string, error) { + f.here = append(f.here, id) + return "running task 7 Repair export here", nil +} + +func computerRecoveryApp(t *testing.T, state session.TaskState, remote *session.TaskRemoteRecord, words string) (*app, *surfaceTaskComputerAgent, session.TaskIndexEntry) { + t.Helper() + a, base, _ := taskApp(t) + f := &surfaceTaskComputerAgent{taskFake: base} + a.agent = f + a.taskComputers = surfaceTaskComputers{{Device: surfaceComputerB, Name: "work computer", CanTakeTasks: true}} + entry := session.TaskIndexEntry{ID: "7", SessionID: a.taskSheetSelfID(), Title: "Repair export", Status: string(state), Computer: "work computer", Unreachable: remote.Unreachable} + drive(t, a, taskEventMsg{gen: a.taskGen, ev: update(7, entry.Title, state, session.TaskNotice{Computer: entry.Computer, Unreachable: remote.Unreachable, Remote: remote, Doing: words, Report: words})}) + a.taskSheet.detail, a.taskSheet.detailOn = entry, true + return a, f, entry +} + +// TestAComputerThatStoppedAnsweringOffersRunItHere maps to DESIGN contracts 9 +// and 11: the missing computer is said without claiming it stopped, and one +// deliberate action reaches the session's local replacement operation. +func TestAComputerThatStoppedAnsweringOffersRunItHere(t *testing.T) { + const words = "work computer stopped answering — it may still be working" + for _, surface := range []string{"card", "page"} { + t.Run(surface, func(t *testing.T) { + remote := &session.TaskRemoteRecord{Unreachable: true} + a, f, entry := computerRecoveryApp(t, session.TaskRunning, remote, words) + rows, _, _, _ := a.taskCardFrame(120, 40) + if text := plain(strings.Join(rows, "\n")); !strings.Contains(text, words) || strings.Count(text, "h run it here") != 1 { + t.Fatalf("the card does not give one local choice:\n%s", text) + } + if surface == "card" { + drive(t, a, taskMsg(a.taskCardKey("h"))) + } else { + a.taskSheet.detailOn = false + a.room = a.newRoom(7, entry.Title) + if hint := a.roomHint(); !strings.Contains(hint, "h run it here") { + t.Fatalf("the page lost the local choice: %q", hint) + } + cmd, took := a.roomKey(key("h")) + if !took { + t.Fatal("the page did not take its local-choice key") + } + drive(t, a, taskMsg(cmd)) + } + if len(f.here) != 1 || f.here[0] != 7 { + t.Fatalf("the local choice reached the wrong task: %v", f.here) + } + }) + } +} + +// TestAComputerNotTakingTasksSaysToRunServeOnIt maps to DESIGN contract 8: +// the session's actual refusal is retained beside the local replacement verb. +func TestAComputerNotTakingTasksSaysToRunServeOnIt(t *testing.T) { + const words = "work computer is not taking tasks right now — run codeaf serve on it" + a, f, _ := computerRecoveryApp(t, session.TaskInterrupted, &session.TaskRemoteRecord{StartFailed: true}, words) + rows, _, _, _ := a.taskCardFrame(120, 40) + if text := plain(strings.Join(rows, "\n")); !strings.Contains(text, words) || strings.Count(text, "h run it here") != 1 { + t.Fatalf("the start failure does not explain its two choices:\n%s", text) + } + drive(t, a, taskMsg(a.taskCardKey("h"))) + if len(f.here) != 1 || f.here[0] != 7 { + t.Fatalf("the failed start did not reach RunTaskHere: %v", f.here) + } +} + +// TestAnInterruptedRemoteTaskOffersRunItHere covers DESIGN section 4's +// interrupted case without treating a local interrupted task as remote work. +func TestAnInterruptedRemoteTaskOffersRunItHere(t *testing.T) { + a, _, entry := computerRecoveryApp(t, session.TaskInterrupted, &session.TaskRemoteRecord{}, "") + rows, _, _, _ := a.taskCardFrame(120, 40) + if text := plain(strings.Join(rows, "\n")); !strings.Contains(text, "interrupted on work computer") || !strings.Contains(text, "h run it here") { + t.Fatalf("the interrupted remote run has no local choice:\n%s", text) + } + drive(t, a, taskEventMsg{gen: a.taskGen, ev: update(7, entry.Title, session.TaskRunning, session.TaskNotice{})}) + rows, _, _, _ = a.taskCardFrame(120, 40) + if text := plain(strings.Join(rows, "\n")); strings.Contains(text, "work computer") || strings.Contains(text, "run it here") { + t.Fatalf("the local replacement kept the remote placement:\n%s", text) + } +} + +// TestTheRunTasksOnRowListsPairedComputersAndDefaultsToThisComputer maps to +// DESIGN contract 7: names are shown, device identities are saved, and an +// unavailable preference falls back to this computer. +func TestTheRunTasksOnRowListsPairedComputersAndDefaultsToThisComputer(t *testing.T) { + a, _ := sheetApp(t) + a.taskComputers = surfaceTaskComputers{ + {Device: surfaceComputerB, Name: "Abir's Mac", CanTakeTasks: true}, + {Device: surfaceComputerC, Name: "work computer", CanTakeTasks: true}, + {Device: surfaceComputerWindows, Name: "Windows computer"}, + } + drive(t, a, key("ctrl+,")) + cursorTo(t, a, config.KeyTaskRunsOn) + if text := plain(frame(a)); !strings.Contains(text, "Run tasks on") || !strings.Contains(text, "this computer") || !strings.Contains(text, "where a task goes when you do not name a computer") { + t.Fatalf("the row does not explain its default:\n%s", text) + } + drive(t, a, key("enter")) + if a.sheet.choice == nil { + t.Fatal("Run tasks on did not offer computers") + } + text := plain(frame(a)) + for _, name := range []string{"this computer", "Abir's Mac", "work computer"} { + if !strings.Contains(text, name) { + t.Fatalf("the list does not offer %q:\n%s", name, text) + } + } + if strings.Contains(text, "Windows computer") || strings.Contains(text, surfaceComputerB) { + t.Fatalf("the list offers an unavailable computer or exposes its id:\n%s", text) + } + drive(t, a, key("home"), key("down"), key("enter")) + if got := config.TaskRunsOnAt(a.profileDir); got != surfaceComputerB { + t.Fatalf("the choice saved %q instead of the device id", got) + } + if text := plain(frame(a)); !strings.Contains(text, "Abir's Mac") || strings.Contains(text, surfaceComputerB) { + t.Fatalf("the saved row does not show its computer's name:\n%s", text) + } + chooseSheetValue(t, a, "") + if got := config.TaskRunsOnAt(a.profileDir); got != "" { + t.Fatalf("this computer did not restore the default: %q", got) + } + if err := config.WriteTaskRunsOn(a.profileDir, surfaceComputerRemoved); err != nil { + t.Fatal(err) + } + a.sheet.build() + if text := plain(frame(a)); !strings.Contains(text, "this computer") || strings.Contains(text, surfaceComputerRemoved) { + t.Fatalf("a removed computer did not fall back to this computer:\n%s", text) + } +} + +// TestARemoteTaskSaysRunningOnThenRanOn maps to DESIGN contract 2: placement +// follows the task across the rail, roster, room and saved result. +func TestARemoteTaskSaysRunningOnThenRanOn(t *testing.T) { + a, _, _ := taskApp(t) + a.taskComputers = surfaceTaskComputers{{Device: surfaceComputerB, Name: "work computer", CanTakeTasks: true}} + remote := &session.TaskRemoteRecord{Executor: session.TaskExecutor{Device: surfaceComputerB, Name: "work computer"}} + entry := session.TaskIndexEntry{ID: "7", SessionID: a.taskSheetSelfID(), Title: "Repair export", Status: string(session.TaskRunning), Computer: "work computer"} + drive(t, a, taskEventMsg{gen: a.taskGen, ev: update(7, entry.Title, session.TaskRunning, session.TaskNotice{Computer: entry.Computer, Remote: remote, Doing: "running on work computer"})}) + node := a.tasks[7] + for surface, text := range map[string]string{ + "rail": plain(strings.Join(a.railUnder(node, 100), "\n")), + "Tasks": plain(tasksRow(tasksLine{item: &tasksItem{entry: entry, runs: true}}, 160, a.now(), tasksSort{}, a.pal, false)), + "compact Tasks": plain(tasksCardTail(tasksItem{entry: entry, runs: true}, a.now())), + "page": plain(strings.Join(a.taskCardBody(entry, 120), "\n")), + "room": plain(a.roomStateWord(node)), + } { + if strings.Count(text, "running on work computer") != 1 { + t.Errorf("%s does not say where the work runs:\n%s", surface, text) + } + } + entry.Status, entry.RanElsewhere = string(session.TaskDone), true + remote.Finished, remote.Settled = true, true + drive(t, a, taskEventMsg{gen: a.taskGen, ev: update(7, entry.Title, session.TaskDone, session.TaskNotice{Computer: entry.Computer, RanElsewhere: true, Remote: remote, Doing: "ran on work computer"})}) + for surface, text := range map[string]string{ + "saved card": plain(strings.Join(a.taskCardBody(entry, 120), "\n")), + "room": plain(a.roomStateWord(a.tasks[7])), + } { + if !strings.Contains(text, "ran on work computer") { + t.Errorf("%s lost the computer after finishing:\n%s", surface, text) + } + } + entry.Computer, entry.RanElsewhere = "", false + entry.ID, entry.SessionID = "8", "local conversation" + if text := plain(strings.Join(a.taskCardBody(entry, 120), "\n")); strings.Contains(text, "ran on") || strings.Contains(text, "this computer") { + t.Fatalf("local work drew placement words:\n%s", text) + } +} diff --git a/internal/tui3/taskmention.go b/internal/tui3/taskmention.go index 3ab7c9c0c7..11ad189296 100644 --- a/internal/tui3/taskmention.go +++ b/internal/tui3/taskmention.go @@ -202,6 +202,7 @@ func (a *app) adoptFarTaskRows(rows []session.TaskIndexEntry) { node.label = firstNonEmpty(strings.TrimSpace(row.Title), strings.TrimSpace(row.Label)) node.title = taskTitleOf(node.label, "", id) node.state = session.TaskState(row.Status) + node.computer, node.unreachable, node.ranElsewhere = row.Computer, row.Unreachable, row.RanElsewhere if row.Live() { a.anchorFarLiveNode(node, row) } else { diff --git a/internal/tui3/taskpane.go b/internal/tui3/taskpane.go index 86bed7d19d..61ab4d7bfc 100644 --- a/internal/tui3/taskpane.go +++ b/internal/tui3/taskpane.go @@ -349,6 +349,11 @@ func (a *app) taskPaneRecord(item tasksItem, width int) []taskPaneRow { } var facts []string + if placement := a.taskEntryComputerWords(entry); placement != "" { + for _, row := range wrap(placement, width) { + facts = append(facts, pal.dim(row)) + } + } if line := a.taskPaneFacts(entry, width); line != "" { facts = append(facts, fit(pal.dim(line), width)) } diff --git a/internal/tui3/taskrecord.go b/internal/tui3/taskrecord.go index c807f9b400..35bfeb9b64 100644 --- a/internal/tui3/taskrecord.go +++ b/internal/tui3/taskrecord.go @@ -185,6 +185,7 @@ const ( taskCardHitMention taskCardHitStronger taskCardHitOutput + taskCardHitRunHere ) // back reports whether a row of the card is the way back to the list. @@ -436,6 +437,8 @@ func (a *app) taskCardKey(key string) tea.Cmd { return cmd } switch key { + case taskRunHereKey: + return a.runTaskHere(entry) case "o": return a.openTaskResultOutput(entry) case "r": @@ -515,6 +518,9 @@ func (a *app) taskCardPress(x, y int) tea.Cmd { if hits[y] == taskCardHitOutput { return a.openTaskResultOutput(a.taskSheet.detail) } + if hits[y] == taskCardHitRunHere { + return a.runTaskHere(a.taskSheet.detail) + } if hits[y] == taskCardHitStronger { return a.redoTaskStronger(a.taskSheet.detail) } @@ -585,6 +591,10 @@ func (a *app) taskCardFrame(width, height int) ([]string, []taskCardHit, int, in head := len(lines) room := height - head - taskCardFoot + runHere := a.taskCanRunHere(entry) && width-2 >= len(taskRunHereVerb) + if runHere { + room-- + } stronger := a.taskCanRedoStronger(entry) && width-2 >= len(taskStrongerWord) if stronger { room-- @@ -622,6 +632,9 @@ func (a *app) taskCardFrame(width, height int) ([]string, []taskCardHit, int, in for i := 0; i < drawn; i++ { add(" "+body[a.taskSheet.detailTop+i], taskCardHitNone) } + if runHere { + add(" "+pal.underline(taskRunHereVerb), taskCardHitRunHere) + } if stronger { add(" "+pal.underline(taskStrongerWord), taskCardHitStronger) } @@ -998,6 +1011,14 @@ func (a *app) taskCardBody(entry session.TaskIndexEntry, width int) []string { if line := a.taskCardWhenLine(entry); line != "" { bands = append(bands, []string{pal.ink(fit(line, width))}) } + placement := a.taskEntryComputerWords(entry) + if placement != "" { + var rows []string + for _, line := range wrap(placement, width) { + rows = append(rows, pal.dim(line)) + } + bands = append(bands, rows) + } // THE RECOVERY BAND STANDS WHERE THE OUTCOME WOULD, because it is what this // card was opened to say: work another window is running has no outcome and // never will here, and the sentence a person came for is where the work is @@ -1016,7 +1037,7 @@ func (a *app) taskCardBody(entry session.TaskIndexEntry, width int) []string { bands = append(bands, result) } // Identical result and landing prose needs one paragraph; both original fields remain intact. - if outcome := strings.TrimSpace(entry.Outcome); outcome != "" && outcome != displayedResult { + if outcome := strings.TrimSpace(entry.Outcome); outcome != "" && outcome != displayedResult && (placement == "" || strings.TrimSuffix(outcome, taskRunHereSuffix) != placement) { var said []string for _, wrapped := range wrap(outcome, width) { said = append(said, pal.ink(wrapped)) diff --git a/internal/tui3/taskretry.go b/internal/tui3/taskretry.go index b1299d927d..afc20460f4 100644 --- a/internal/tui3/taskretry.go +++ b/internal/tui3/taskretry.go @@ -90,6 +90,12 @@ func (a *app) taskRetried(msg taskRetriedMsg) { // The live graph overrides a saved landing while preserving its transcript and identity. func (a *app) currentTaskEntry(entry session.TaskIndexEntry) session.TaskIndexEntry { node := a.taskSheetNodeFor(&entry) + if node != nil { + if node.computer != "" || entry.Computer != "" { + entry.Status, entry.Ending = string(node.state), node.ending + } + entry.Computer, entry.Unreachable, entry.RanElsewhere = node.computer, node.unreachable, node.ranElsewhere + } if node != nil && node.crew != nil { entry.Crew = node.crew } diff --git a/internal/tui3/tasksplace.go b/internal/tui3/tasksplace.go index 4fd31d6119..5bea8cd931 100644 --- a/internal/tui3/tasksplace.go +++ b/internal/tui3/tasksplace.go @@ -135,6 +135,8 @@ func (i tasksItem) pick() bool { return true } // ([app.recordRuns], asked once in place_tasks.go), because a reading that could // ask the app a question would be a reading that could ask it at paint time. type tasksMine struct { + // The capability travels with the reading so old records cannot enable it. + taskComputers bool // row is this conversation, as a row of the world — what a row of this // project's index is labelled with when the world scan has never seen the // conversation that ran it. @@ -182,6 +184,7 @@ type tasksChatView struct { // tasksReading is everything drawing and routing need from one world reading. // The seen stamp travels with the snapshot across place adapters. type tasksReading struct { + taskComputers bool // layouts is optional scratch owned by one body's paint, never a reading // retained across input. Cursor, pane, and body readers share each width. layouts map[int][]tasksLine @@ -267,7 +270,7 @@ func tasksKeyOf(entry session.TaskIndexEntry) tasksKey { // seconds ago and are the only authority for work that has not landed. func readTasks(world session.World, mine tasksMine, win session.UsageWindow, by tasksSort, seen, now time.Time) tasksReading { by = tasksSort{back: by.back} - r := tasksReading{win: win.Normalized(), seen: seen, now: now, tilde: mine.tilde, order: by} + r := tasksReading{win: win.Normalized(), seen: seen, now: now, tilde: mine.tilde, order: by, taskComputers: mine.taskComputers} // THE ROWS THE RUN'S STORE ANSWERS FOR ARE TAKEN OUT FIRST, before anything // reads them: they are drawn as the store's own plan rows below, and this is // the one line that keeps the pair from being drawn twice or drawn as the @@ -1626,6 +1629,11 @@ func (r tasksReading) paint(lines []tasksLine, i, width int, pal palette, lit bo return "" } line, lead := lines[i], tasksBareLead + if !r.taskComputers && line.item != nil && line.item.entry.Computer != "" { + item := *line.item + item.entry.Computer, item.entry.Unreachable, item.entry.RanElsewhere = "", false, false + line.item = &item + } room := width - ansi.StringWidth(lead) if room < 1 { room = 1 @@ -2028,6 +2036,9 @@ func tasksRow(line tasksLine, width int, now time.Time, by tasksSort, pal palett state, second = planStateField(item), planSpendField(item) } label := tasksLabel(item.entry) + if words := taskComputerWords(item.entry.Computer, item.entry.Unreachable, item.entry.RanElsewhere, session.TaskState(item.entry.Status)); words != "" { + label += rowSep + words + } if item.plan != nil && item.plan.Total > 0 { label += " " + planProgress(*item.plan, width, pal) } @@ -2157,10 +2168,17 @@ func tasksEntryAt(entry session.TaskIndexEntry, now time.Time) time.Time { // GRADES it was the state word again in different clothes. func tasksMiddle(item tasksItem) string { entry := item.entry + placement := taskComputerWords(entry.Computer, entry.Unreachable, entry.RanElsewhere, session.TaskState(entry.Status)) if activity := strings.TrimSpace(entry.Activity); activity != "" { + if placement != "" { + return placement + rowSep + activity + } return activity } var parts []string + if placement != "" { + parts = append(parts, placement) + } // A PLAN ROW CARRIES WHAT THE STORE KNOWS: its steps and its money // (taskplan.go), the two figures a phone card otherwise has no room for. if item.plan != nil { diff --git a/internal/tui3/taskstatus.go b/internal/tui3/taskstatus.go index 902707c088..2f76711316 100644 --- a/internal/tui3/taskstatus.go +++ b/internal/tui3/taskstatus.go @@ -27,7 +27,7 @@ func (a *app) taskStatus(node *taskNode) session.TaskStatus { Ending: node.ending, Life: node.phase, Kind: node.kind, - Phase: node.doing, + Phase: taskDoingWords(node), Gap: node.mending, Hold: node.waiting, Waits: a.taskWaitTitles(node), diff --git a/internal/tui3/tui3.go b/internal/tui3/tui3.go index 55f8d8ee03..e037ddda1f 100644 --- a/internal/tui3/tui3.go +++ b/internal/tui3/tui3.go @@ -428,6 +428,9 @@ type CodexFlow interface { // Options configures one surface. type Options struct { + // TaskComputers is absent where this conversation cannot place tasks on + // paired computers, so the surface can leave those controls out together. + TaskComputers session.TaskComputers // Knowledge is the native vault on this machine. Hosted windows leave it nil. Knowledge *knowledge.Vault // WaitForRelease is a local lifecycle hint. A coordinating terminal must From 803efcaef8d2510c680d8fe4bea6a31a379ac420 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Fri, 30 Oct 2026 21:02:26 -0400 Subject: [PATCH 11/34] chat: find paired task computers on an ordinary launch and a connected chat A launch without CODEAF_PROFILE_DIR handed the task-computer door an empty profile, so it never found this home's identity, and a connected conversation whose adaptive lanes chose the plain builder dropped the door. Co-Authored-By: Claude Opus 5.5 --- cmd/codeaf/chatv3_lanes.go | 5 ++ cmd/codeaf/chatv3_taskcomputers.go | 6 ++ .../chatv3_taskcomputers_default_test.go | 55 +++++++++++++++++++ 3 files changed, 66 insertions(+) create mode 100644 cmd/codeaf/chatv3_taskcomputers_default_test.go diff --git a/cmd/codeaf/chatv3_lanes.go b/cmd/codeaf/chatv3_lanes.go index 99676727af..4a70453234 100644 --- a/cmd/codeaf/chatv3_lanes.go +++ b/cmd/codeaf/chatv3_lanes.go @@ -75,6 +75,11 @@ func v3LanesOverWire() v3Lanes { // are one fact. A door that took the lanes and then picked its own builder could // turn the verb on with nothing behind it. func v3Shape(cfg session.Config, lanes v3Lanes) (session.Config, func(session.Config) (*session.Agent, error)) { + // Task placement works across the connection independently of adaptive + // run lanes, so disabling those lanes must not hide paired computers. + if cfg.TaskComputers == nil { + cfg.TaskComputers = v3TaskComputers(cfg.ProfileDir) + } if !lanes.Designs { // Nil is the honest way to say the designer is not among the things this // conversation can do, so the model says as much instead of starting two diff --git a/cmd/codeaf/chatv3_taskcomputers.go b/cmd/codeaf/chatv3_taskcomputers.go index d52168fcc9..551930382f 100644 --- a/cmd/codeaf/chatv3_taskcomputers.go +++ b/cmd/codeaf/chatv3_taskcomputers.go @@ -8,6 +8,7 @@ import ( "github.com/Agent-Field/codeaf/internal/blobstore" "github.com/Agent-Field/codeaf/internal/cellstore" + "github.com/Agent-Field/codeaf/internal/home" "github.com/Agent-Field/codeaf/internal/identity" "github.com/Agent-Field/codeaf/internal/pair" "github.com/Agent-Field/codeaf/internal/reqsign" @@ -20,6 +21,11 @@ import ( // v3TaskComputers builds no client while this home is quiet, unpaired or on an // unsupported platform. A nil door also keeps the proposal argument absent. func v3TaskComputers(profile string) session.TaskComputers { + // Identity storage takes a concrete home, while an unset profile means + // the ordinary state root to the rest of the conversation configuration. + if profile == "" { + profile = home.Dir() + } if runtime.GOOS == "windows" || syncsetup.Quiet(profile) { return nil } diff --git a/cmd/codeaf/chatv3_taskcomputers_default_test.go b/cmd/codeaf/chatv3_taskcomputers_default_test.go new file mode 100644 index 0000000000..2461ad1a2f --- /dev/null +++ b/cmd/codeaf/chatv3_taskcomputers_default_test.go @@ -0,0 +1,55 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "runtime" + "testing" + + "github.com/Agent-Field/codeaf/internal/devname" + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/session" +) + +// DESIGN 1 requires the ordinary chat launch to find the paired identity in +// the same home that serve uses, without a separate profile override. +func TestConversationTaskComputersUseTheDefaultHomeWhenTheProfileIsUnset(t *testing.T) { + _ = pairedTaskDefaultHome(t) + computers := v3TaskComputers("") + if computers == nil || computers.ThisComputer() != "chat computer" { + t.Fatal("the normal profile hides paired task computers") + } +} + +// DESIGN 1 also applies over the ordinary engine connection; disabling an +// unrelated run lane must not remove the task placement that /task --on uses. +func TestConnectedConversationsKeepPairedTaskPlacement(t *testing.T) { + profile := pairedTaskDefaultHome(t) + cfg, _ := v3Shape(session.Config{ProfileDir: profile}, v3LanesOverWire()) + if cfg.TaskComputers == nil || cfg.TaskComputers.ThisComputer() != "chat computer" { + t.Fatal("the connected conversation hides paired task placement") + } +} + +func pairedTaskDefaultHome(t *testing.T) string { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("paired tasks require Linux or macOS") + } + profile := t.TempDir() + t.Setenv("HOME", t.TempDir()) + t.Setenv("CODEAF_HOME", profile) + t.Setenv("CODEAF_PROFILE_DIR", "") + t.Setenv("CODEAF_TELEMETRY", "off") + t.Setenv("DO_NOT_TRACK", "1") + server := httptest.NewServer(http.NotFoundHandler()) + t.Cleanup(server.Close) + t.Setenv("CODEAF_SYNC_URL", server.URL) + if _, err := identity.Ensure(profile); err != nil { + t.Fatal(err) + } + if _, err := devname.Set(profile, "chat computer"); err != nil { + t.Fatal(err) + } + return profile +} From 9e8d7ba1f7241d45cca70b9ff9e8c5d89c549c6a Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Fri, 30 Oct 2026 21:02:26 -0400 Subject: [PATCH 12/34] e2e: a task runs on the other home and lands here Two homes, one identity, the real codeaf relay and codeaf serve, and a scripted model endpoint per home. The task's model calls reach only the other home, its file and commit land in this project, its steps arrive in order and its spend is on this ledger once; a computer that is not serving says so and run it here lands the same file. Co-Authored-By: Claude Opus 5.5 --- internal/e2e/task_other_home_e2e_test.go | 705 +++++++++++++++++++++++ 1 file changed, 705 insertions(+) create mode 100644 internal/e2e/task_other_home_e2e_test.go diff --git a/internal/e2e/task_other_home_e2e_test.go b/internal/e2e/task_other_home_e2e_test.go new file mode 100644 index 0000000000..5f3892bc98 --- /dev/null +++ b/internal/e2e/task_other_home_e2e_test.go @@ -0,0 +1,705 @@ +//go:build e2e + +package e2e + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "io" + "math" + "net" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "runtime" + "strconv" + "strings" + "sync" + "syscall" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/cellstore" + "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/devname" + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/remote" + "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/syncsetup" +) + +const otherHomeModel = "stub/paired-task" +const otherHomeFile = "made-on-other-home.txt" +const otherHomeContents = "the paired worker wrote this\n" +const otherHomeCommit = "write the paired task result" +const otherHomeBrief = "Write made-on-other-home.txt containing exactly the paired worker wrote this followed by a newline, commit it with subject write the paired task result, and finish." +const otherHomeCallCost = 0.0125 + +// DESIGN 1–5: the connection invokes the session entry used by /task --on. +// Only the two providers are scripted; both engines, the child, encrypted +// transport, plan store, ledger and landing are the shipped binary's own. +func TestATaskRunsOnTheOtherHomeAndLandsHere(t *testing.T) { + began := time.Now() + f := newOtherHomeTask(t) + f.brainB.gate = make(chan struct{}) + f.startServing(t) + a := f.openConversation(t, false) + f.say(t, a, "Remember ORIGIN_TRANSCRIPT_ONLY and reply CHAT_STAYS_HERE.") + id := f.startTask(t, a) + // The worker cannot receive its finishing call until its first step is + // readable here, so an implementation that buffers until finish fails. + otherHomeWait(t, f.ctx, "the first step while the other worker is running", func() bool { + page, ok, err := a.ReadPlanTaskPage(strconv.FormatUint(id, 10)) + if err != nil || !ok { + return false + } + for _, step := range page.Steps { + if strings.Contains(step.Command, "TASK_INSPECTED_HERE") { + return true + } + } + return false + }) + f.waitNotice(t, a, id, func(n session.TaskNotice) bool { + return n.Computer == "work computer" && n.State == session.TaskRunning && !n.RanElsewhere + }) + close(f.brainB.gate) + f.waitDone(t, a, id) + f.waitNotice(t, a, id, func(n session.TaskNotice) bool { return n.Computer == "work computer" && n.RanElsewhere }) + + t.Run("model_calls_reach_only_the_other_home", func(t *testing.T) { + // Contract 1 distinguishes actual worker prompts from naming and chat calls. + ac, aw := f.brainA.counts() + bc, bw := f.brainB.counts() + if ac == 0 || aw != 0 || bc == 0 || bw < 2 { + t.Fatalf("provider calls: here=%d (worker=%d), there=%d (worker=%d)", ac, aw, bc, bw) + } + }) + t.Run("the_file_lands_in_this_project", func(t *testing.T) { + // Contract 3 includes the worker's commit and excludes the chat's record. + f.assertFile(t) + if got := f.git(t, "log", "--format=%s"); !strings.Contains("\n"+got+"\n", "\n"+otherHomeCommit+"\n") { + t.Fatalf("the worker's commit is absent from the project history: %s", got) + } + transcript := a.Client().Welcome().SessionFile + raw, err := os.ReadFile(transcript) + if err != nil || !strings.Contains(string(raw), "ORIGIN_TRANSCRIPT_ONLY") || !strings.HasPrefix(transcript, f.a+string(os.PathSeparator)) { + t.Fatalf("origin transcript = %q, %v", transcript, err) + } + err = filepath.WalkDir(f.b, func(path string, entry os.DirEntry, err error) error { + if err != nil { + return err + } + if entry.IsDir() || !(strings.HasSuffix(path, ".jsonl") || strings.HasSuffix(path, ".json") || strings.HasSuffix(path, ".md")) { + return nil + } + data, err := os.ReadFile(path) + if err != nil { + return err + } + if strings.Contains(string(data), "ORIGIN_TRANSCRIPT_ONLY") || entry.Name() == filepath.Base(transcript) { + t.Errorf("the conversation travelled to %s", path) + } + return nil + }) + if err != nil { + t.Fatal(err) + } + }) + t.Run("the_steps_arrived_in_order", func(t *testing.T) { + // Contract 2 reads A's ordinary task page, backed by its plan trajectory. + page, ok, err := a.ReadPlanTaskPage(strconv.FormatUint(id, 10)) + if err != nil || !ok { + t.Fatalf("task page: %v, %v", ok, err) + } + first, second := -1, -1 + for i, step := range page.Steps { + if strings.Contains(step.Command, "TASK_INSPECTED_HERE") { + first = i + } + if strings.Contains(step.Command, "git add -- "+otherHomeFile) { + second = i + } + } + if first < 0 || second <= first { + t.Fatalf("worker steps missing or reordered: %+v", page.Steps) + } + }) + t.Run("spend_is_on_this_ledger_once", func(t *testing.T) { + // Contract 5 compares the mirrored calls with B's own per-call charges. + aLines := otherHomeLedger(t, f.a) + bLines := otherHomeLedger(t, f.b) + seen := map[string]bool{} + transcript, err := os.Open(a.Client().Welcome().SessionFile) + if err != nil { + t.Fatal(err) + } + var header struct { + ID string `json:"id"` + } + err = json.NewDecoder(transcript).Decode(&header) + _ = transcript.Close() + if err != nil || header.ID == "" { + t.Fatalf("conversation header: %+v, %v", header, err) + } + var here, there float64 + count := 0 + for _, line := range aLines { + if line.RemoteEvent == "" { + continue + } + if seen[line.RemoteEvent] || line.Computer != "work computer" || line.Task != strconv.FormatUint(id, 10) || line.Session != header.ID { + t.Fatalf("duplicate or unattributed remote charge: %+v", line) + } + seen[line.RemoteEvent] = true + count++ + here += line.USD + } + for _, line := range bLines { + there += line.USD + } + bc, _ := f.brainB.counts() + if count != bc || math.Abs(here-there) > 1e-9 || math.Abs(here-float64(bc)*otherHomeCallCost) > 1e-9 { + t.Fatalf("charges here=%d/$%f, there=$%f, provider calls=%d", count, here, there, bc) + } + page, err := a.Client().Ledger(time.Time{}) + if err != nil { + t.Fatal(err) + } + var pageUSD float64 + pageCalls := 0 + for _, line := range page.Lines { + if line.RemoteEvent != "" { + pageUSD += line.USD + pageCalls += line.Calls + } + } + if pageCalls != count || math.Abs(pageUSD-here) > 1e-9 { + t.Fatalf("spend page calls=%d/$%f, ledger calls=%d/$%f", pageCalls, pageUSD, count, here) + } + }) + t.Run("private_copies_wait_for_review_and_apply", func(t *testing.T) { + // Contract 4 takes another real remote result through the person-only doors. + if err := os.Remove(filepath.Join(f.project, otherHomeFile)); err != nil { + t.Fatal(err) + } + f.git(t, "add", "--", otherHomeFile) + f.git(t, "commit", "-qm", "clear the previous result") + private := f.openConversation(t, true) + privateID := f.startTask(t, private) + f.waitDone(t, private, privateID) + if _, err := os.Stat(filepath.Join(f.project, otherHomeFile)); !os.IsNotExist(err) { + t.Fatalf("private result landed before apply: %v", err) + } + var review session.TaskWorkspaceReview + // The done notice precedes the final store close; Review itself is the + // public readiness check and must keep refusing while that join lives. + otherHomeWait(t, f.ctx, "the private result to be ready for review", func() bool { + var err error + review, err = private.ReviewTaskWorkspaceRoot(f.ctx, privateID, "work") + if err == nil { + return true + } + if err.Error() != "this workspace layout may still have a writer or checker; its private work was retained" { + t.Fatal(err) + } + return false + }) + if !strings.Contains(review.Diff, "+the paired worker wrote this") || review.Snapshot == "" { + t.Fatalf("review: %+v", review) + } + // A person's later edit must not change the snapshot Apply installs. + if err := os.WriteFile(filepath.Join(review.Copy, otherHomeFile), []byte("unreviewed later edit\n"), 0600); err != nil { + t.Fatal(err) + } + if _, err := private.ApplyTaskWorkspaceRoot(f.ctx, privateID, "work"); err != nil { + t.Fatal(err) + } + f.assertFile(t) + }) + t.Logf("two-home real-binary wall time: %s", time.Since(began).Round(time.Millisecond)) +} + +// DESIGN 8: the exact failed-start sentence and explicit local replacement +// cross the same connection doors as the card's run-it-here action. +func TestATaskForAComputerNotServingSaysSoAndRunsHere(t *testing.T) { + f := newOtherHomeTask(t) + stop := f.startServing(t) + stop() + a := f.openConversation(t, false) + id := f.startTask(t, a) + want := "work computer is not taking tasks right now — run codeaf serve on it" + f.waitNotice(t, a, id, func(notice session.TaskNotice) bool { + return notice.Remote != nil && notice.Remote.StartFailed && strings.Contains(notice.Report, want) + }) + if _, err := a.RunTaskHere(f.ctx, id); err != nil { + t.Fatal(err) + } + f.waitDone(t, a, id) + f.assertFile(t) + if _, workers := f.brainA.counts(); workers < 2 { + t.Fatal("local replacement did not use this home's worker") + } + if calls, _ := f.brainB.counts(); calls != 0 { + t.Fatalf("stopped home received %d calls", calls) + } +} + +type otherHomeTask struct { + ctx context.Context + a, b, project, relay, product string + brainA, brainB *otherHomeBrain +} + +func newOtherHomeTask(t *testing.T) *otherHomeTask { + t.Helper() + product := binary(t) + if runtime.GOOS != "linux" && runtime.GOOS != "darwin" { + t.Skip("paired tasks need Linux or macOS on both computers") + } + short := func(prefix string) string { + dir, err := os.MkdirTemp("/tmp", prefix) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.RemoveAll(dir) }) + return dir + } + f := &otherHomeTask{a: short("rn-l1-a"), b: short("rn-l1-b"), product: product} + var cancel context.CancelFunc + f.ctx, cancel = context.WithTimeout(t.Context(), 3*time.Minute) + t.Cleanup(cancel) + t.Setenv("HOME", f.a) + t.Setenv("CODEAF_HOME", f.a) + t.Setenv("CODEAF_TELEMETRY", "off") + t.Setenv("DO_NOT_TRACK", "1") + f.brainA, f.brainB = newOtherHomeBrain(t, "synthetic-home-a-key"), newOtherHomeBrain(t, "synthetic-home-b-key") + for i, home := range []string{f.a, f.b} { + key := []string{f.brainA.key, f.brainB.key}[i] + rows := map[string]any{"api_key": key, "memory.enabled": false, "tools.approvalMode": "allow", "model_pool": "off", "task.audit": "off", "task.start": "single", "task.max_load": 0, "task.min_free_mb": 0, "setup_seen_at": time.Now().UTC().Format(time.RFC3339)} + for _, name := range []string{config.KeyChatModel, config.KeyTaskModel, config.KeyTierWorkerModel, config.KeyTierLowModel, config.KeyTierHighModel, config.KeyTierReflexModel, config.KeyTierMastermindModel} { + rows[name] = otherHomeModel + } + raw, _ := json.Marshal(rows) + if err := os.WriteFile(filepath.Join(home, "config.json"), raw, 0600); err != nil { + t.Fatal(err) + } + } + var addr string + for port := 8900; port <= 8999; port++ { + ln, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port)) + if err == nil { + addr = ln.Addr().String() + _ = ln.Close() + break + } + } + if addr == "" { + t.Fatal("no free task relay port in 8900–8999") + } + f.relay = "http://" + addr + relay := guardedCommand(t, f.ctx, f.a, f.env(f.a), product, "relay", "--listen", addr, "--store", filepath.Join(f.a, "relay-store"), "--quiet") + otherHomeProcess(t, relay, filepath.Join(f.a, "relay.log")) + otherHomeWait(t, f.ctx, "local relay", func() bool { + conn, err := net.DialTimeout("tcp", addr, 100*time.Millisecond) + if err != nil { + return false + } + _ = conn.Close() + return true + }) + t.Setenv("CODEAF_SYNC_URL", f.relay) + id, err := identity.Ensure(f.a) + if err != nil { + t.Fatal(err) + } + if _, err := identity.Adopt(f.b, id, false); err != nil { + t.Fatal(err) + } + for i, home := range []string{f.a, f.b} { + name := []string{"chat computer", "work computer"}[i] + if _, err := devname.Set(home, name); err != nil { + t.Fatal(err) + } + paired, ok, err := syncsetup.Open(home) + if err != nil || !ok { + t.Fatalf("paired home: %v, %v", ok, err) + } + if err := syncsetup.PutOwnDevice(f.ctx, paired.Dir, paired.Identity, paired.Device, name); err != nil { + t.Fatal(err) + } + // Stop only each disposable home's native service, after its engines exit. + t.Cleanup(func() { + matches, _ := filepath.Glob(filepath.Join(home, "v3", "*.sock")) + for _, socket := range matches { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + _ = (cellstore.Daemon{Socket: socket}).Stop(ctx) + cancel() + } + }) + } + f.project = filepath.Join(f.a, "project") + if err := os.Mkdir(f.project, 0700); err != nil { + t.Fatal(err) + } + f.git(t, "init", "-q", "-b", "task-e2e") + if err := os.WriteFile(filepath.Join(f.project, "README.md"), []byte("paired task fixture\n"), 0600); err != nil { + t.Fatal(err) + } + f.git(t, "add", "README.md") + f.git(t, "commit", "-qm", "initial project") + t.Cleanup(func() { + states, _ := filepath.Glob(filepath.Join(f.b, "v3", "taskdoor", "*", "state.json")) + for _, path := range states { + raw, _ := os.ReadFile(path) + var state struct { + PID int `json:"pid"` + Phase string `json:"phase"` + } + if json.Unmarshal(raw, &state) == nil && state.PID > 0 && state.Phase == "running" { + // These groups were launched only by this fixture's task door. + _ = syscall.Kill(-state.PID, syscall.SIGKILL) + } + } + }) + return f +} + +func (f *otherHomeTask) env(home string) []string { + brain := f.brainA + if home == f.b { + brain = f.brainB + } + return []string{"PATH=" + os.Getenv("PATH"), "HOME=" + home, "CODEAF_HOME=" + home, "CODEAF_PROFILE_DIR=", config.APIKeyEnv + "=" + brain.key, + "CODEAF_BASE_URL=" + brain.server.URL, "CODEAF_SYNC_URL=" + f.relay, "CODEAF_RELAY=" + f.relay, "CODEAF_TASK_BELT=bash", + "CODEAF_TELEMETRY=off", "DO_NOT_TRACK=1", "CODEAF_NO_UPDATE_CHECK=1", "CODEAF_NO_AUTOMATIONS=1"} +} + +func (f *otherHomeTask) startServing(t *testing.T) func() { + t.Helper() + log := filepath.Join(f.b, "serve.log") + cmd := guardedCommand(t, f.ctx, f.b, f.env(f.b), f.product, "serve", "--via", f.relay) + cmd.Dir = f.b + stop := otherHomeProcess(t, cmd, log) + otherHomeWait(t, f.ctx, "codeaf serve's task door", func() bool { + raw, _ := os.ReadFile(log) + return strings.Contains(string(raw), "takes tasks from your paired computers") + }) + return stop +} + +type otherHomePipe struct { + io.Reader + io.Writer + close func() error +} + +func (p otherHomePipe) Close() error { return p.close() } + +func (f *otherHomeTask) openConversation(t *testing.T, private bool) *remote.Agent { + t.Helper() + env := append(f.env(f.a), "CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP=0") + if private { + env = append(env, "CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP=1") + } + cmd := guardedCommand(t, f.ctx, f.a, env, f.product, "engine", "--no-host", "--workspace", f.project) + cmd.Dir = f.project + in, err := cmd.StdinPipe() + if err != nil { + t.Fatal(err) + } + out, err := cmd.StdoutPipe() + if err != nil { + t.Fatal(err) + } + log := filepath.Join(f.a, fmt.Sprintf("engine-%v.log", private)) + stop := otherHomeProcess(t, cmd, log) + client, err := remote.Dial(otherHomePipe{Reader: out, Writer: in, close: func() error { return in.Close() }}, "this computer", remote.Hello{Workspace: f.project, Headless: true, Model: otherHomeModel, New: true}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = client.Close(); stop() }) + if !client.Welcome().TaskComputers { + t.Fatal("paired task controls are absent") + } + return client.Agent() +} + +func (f *otherHomeTask) say(t *testing.T, a *remote.Agent, words string) { + t.Helper() + events, err := a.Submit(f.ctx, words) + if err != nil { + t.Fatal(err) + } + for event := range events { + if event.Kind == session.EventError { + t.Fatalf("conversation: %+v", event) + } + } +} + +func (f *otherHomeTask) startTask(t *testing.T, a *remote.Agent) uint64 { + t.Helper() + id, _, _, err := a.StartTaskOn(f.ctx, otherHomeBrief, true, "work computer") + if err != nil { + t.Fatal(err) + } + return id +} + +func (f *otherHomeTask) waitDone(t *testing.T, a *remote.Agent, id uint64) { + t.Helper() + f.waitNotice(t, a, id, func(n session.TaskNotice) bool { + if n.State == session.TaskDone { + return true + } + if n.State != session.TaskRunning && n.State != session.TaskQueued { + t.Fatalf("task ended without done: %+v", n) + } + return false + }) +} + +func (f *otherHomeTask) waitNotice(t *testing.T, a *remote.Agent, id uint64, ready func(session.TaskNotice) bool) { + t.Helper() + events, stop := a.WatchTaskUpdates() + defer stop() + for { + select { + case event, ok := <-events: + if !ok { + t.Fatal("task connection closed") + } + if event.Task != nil && event.Task.ID == id && ready(*event.Task) { + return + } + case <-f.ctx.Done(): + t.Fatalf("waiting for task %d: %v", id, f.ctx.Err()) + } + } +} + +func (f *otherHomeTask) assertFile(t *testing.T) { + t.Helper() + raw, err := os.ReadFile(filepath.Join(f.project, otherHomeFile)) + if err != nil || string(raw) != otherHomeContents { + t.Fatalf("landed file = %q, %v", raw, err) + } +} + +func (f *otherHomeTask) git(t *testing.T, args ...string) string { + t.Helper() + cmd := exec.Command("git", append([]string{"-C", f.project, "-c", "user.name=paired task fixture", "-c", "user.email=task@example.test", "-c", "commit.gpgsign=false"}, args...)...) + cmd.Env = f.env(f.a) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("fixture git %v: %v\n%s", args, err, out) + } + return strings.TrimSpace(string(out)) +} + +func otherHomeProcess(t *testing.T, cmd *exec.Cmd, log string) func() { + t.Helper() + file, err := os.Create(log) + if err != nil { + t.Fatal(err) + } + cmd.Stderr = file + if cmd.Stdout == nil { + cmd.Stdout = file + } + if err := cmd.Start(); err != nil { + _ = file.Close() + t.Fatal(err) + } + done := make(chan error, 1) + go func() { done <- cmd.Wait() }() + var once sync.Once + stop := func() { + once.Do(func() { + _ = cmd.Process.Signal(os.Interrupt) + select { + case <-done: + case <-time.After(10 * time.Second): + _ = cmd.Process.Kill() + <-done + } + _ = file.Close() + }) + } + t.Cleanup(func() { + stop() + if t.Failed() { + raw, _ := os.ReadFile(log) + t.Logf("%s:\n%s", filepath.Base(log), raw) + } + }) + return stop +} + +func otherHomeWait(t *testing.T, ctx context.Context, what string, ready func() bool) { + t.Helper() + for !ready() { + select { + case <-ctx.Done(): + t.Fatalf("waiting for %s: %v", what, ctx.Err()) + case <-time.After(50 * time.Millisecond): + } + } +} + +func otherHomeLedger(t *testing.T, home string) []session.UsageLine { + t.Helper() + file, err := os.Open(filepath.Join(home, "v3", "usage.jsonl")) + if err != nil { + t.Fatal(err) + } + defer file.Close() + var lines []session.UsageLine + scan := bufio.NewScanner(file) + for scan.Scan() { + var line session.UsageLine + if err := json.Unmarshal(scan.Bytes(), &line); err != nil { + t.Fatal(err) + } + lines = append(lines, line) + } + if err := scan.Err(); err != nil { + t.Fatal(err) + } + return lines +} + +type otherHomeBrain struct { + server *httptest.Server + key string + gate chan struct{} + mu sync.Mutex + calls, workers int +} + +func newOtherHomeBrain(t *testing.T, key string) *otherHomeBrain { + t.Helper() + b := &otherHomeBrain{key: key} + b.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/models": + fmt.Fprintf(w, `{"data":[{"id":%q,"canonical_slug":%q,"name":"Paired task fixture","context_length":200000,"pricing":{"prompt":"0.000001","completion":"0.000001"},"supported_parameters":["tools","tool_choice"]}]}`, otherHomeModel, otherHomeModel) + case "/key", "/auth/key", "/credits": + fmt.Fprint(w, `{"data":{"limit_remaining":100,"total_credits":100,"total_usage":0}}`) + case "/chat/completions": + if r.Header.Get("Authorization") != "Bearer "+key { + t.Error("provider received the other home's key") + http.Error(w, "wrong synthetic key", 401) + return + } + var request struct { + Stream bool + Model string + Messages []struct { + Role string + Content json.RawMessage + ToolCalls []struct{ Function struct{ Arguments string } } `json:"tool_calls"` + } + } + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + t.Error(err) + http.Error(w, "bad fixture request", 400) + return + } + var assignment []string + checker := false + turn := 0 + for _, message := range request.Messages { + checker = checker || strings.Contains(string(message.Content), "## Who checks this work") + if message.Role == "tool" && strings.Contains(string(message.Content), "TASK_INSPECTED_HERE") { + turn = max(turn, 1) + } + for _, call := range message.ToolCalls { + if strings.Contains(call.Function.Arguments, "git add -- "+otherHomeFile) { + turn = 2 + } + } + if match := freeConsentAssignment.FindStringSubmatch(string(message.Content)); len(match) == 3 { + assignment = match + } + } + b.mu.Lock() + b.calls++ + if len(assignment) == 3 { + b.workers++ + } + b.mu.Unlock() + if turn == 1 && b.gate != nil { + if request.Stream { + w.Header().Set("Content-Type", "text/event-stream") + } + beat := time.NewTicker(time.Second) + defer beat.Stop() + waiting: + for { + select { + case <-b.gate: + break waiting + case <-r.Context().Done(): + return + case <-beat.C: + if request.Stream { + keepalive(w) + } + } + } + } + var call any + text := "CHAT_STAYS_HERE" + if len(assignment) == 3 { + command := "printf TASK_INSPECTED_HERE" + if turn == 1 { + command = "printf 'the paired worker wrote this\\n' > " + otherHomeFile + " && git add -- " + otherHomeFile + " && git -c user.name='paired worker' -c user.email=worker@example.test -c commit.gpgsign=false commit -qm '" + otherHomeCommit + "' && plandb done " + assignment[1] + " --agent " + assignment[2] + " --result 'The requested file and commit are ready.'" + } + if checker || turn > 1 { + command = "grep -qx 'the paired worker wrote this' " + otherHomeFile + " && plandb done " + assignment[1] + " --agent " + assignment[2] + " --result 'holds: the requested file and commit are ready.'" + } + args, _ := json.Marshal(map[string]string{"command": command}) + call = map[string]any{"index": 0, "id": fmt.Sprintf("paired-%s-%d", assignment[2], turn), "type": "function", "function": map[string]string{"name": "bash", "arguments": string(args)}} + } + otherHomeResponse(w, request.Model, text, call, request.Stream) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(b.server.Close) + return b +} + +func (b *otherHomeBrain) counts() (int, int) { + b.mu.Lock() + defer b.mu.Unlock() + return b.calls, b.workers +} + +func otherHomeResponse(w http.ResponseWriter, model, text string, call any, stream bool) { + message := map[string]any{"role": "assistant", "content": text} + finish := "stop" + if call != nil { + message["content"] = "" + message["tool_calls"] = []any{call} + finish = "tool_calls" + } + usage := map[string]any{"prompt_tokens": 11, "completion_tokens": 7, "total_tokens": 18, "cost": otherHomeCallCost} + if stream { + w.Header().Set("Content-Type", "text/event-stream") + for _, choice := range []map[string]any{{"index": 0, "delta": message, "finish_reason": nil}, {"index": 0, "delta": map[string]any{}, "finish_reason": finish}} { + raw, _ := json.Marshal(map[string]any{"id": "paired-fixture", "model": model, "choices": []any{choice}, "usage": usage}) + fmt.Fprintf(w, "data: %s\n\n", raw) + } + fmt.Fprint(w, "data: [DONE]\n\n") + return + } + _ = json.NewEncoder(w).Encode(map[string]any{"id": "paired-fixture", "model": model, "choices": []any{map[string]any{"index": 0, "message": message, "finish_reason": finish}}, "usage": usage}) +} From abfe77dfe5e5d6616d991399c0e10916792854a4 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Fri, 30 Oct 2026 21:02:26 -0400 Subject: [PATCH 13/34] manual: tasks on another computer A new page answers how a task runs on a paired computer, what running on means, what happens when it stops answering, run it here, the Run tasks on setting and codeaf serve. The /task, propose_task, settings, keys and serve pages say what changed, and the retrieval table holds the questions. Co-Authored-By: Claude Opus 5.5 --- internal/manual/chat/commands.md | 8 + internal/manual/chat/how-tasks-run.md | 26 ++- internal/manual/chat/keys.md | 12 ++ .../chat/reaching-this-machine-without-ssh.md | 9 ++ internal/manual/chat/settings.md | 8 + .../manual/chat/tasks-on-another-computer.md | 149 ++++++++++++++++++ internal/manual/chat_test.go | 10 ++ internal/session/task_computer_manual_test.go | 39 +++++ 8 files changed, 259 insertions(+), 2 deletions(-) create mode 100644 internal/manual/chat/tasks-on-another-computer.md create mode 100644 internal/session/task_computer_manual_test.go diff --git a/internal/manual/chat/commands.md b/internal/manual/chat/commands.md index 5befa40ba7..04c8398f77 100644 --- a/internal/manual/chat/commands.md +++ b/internal/manual/chat/commands.md @@ -1577,6 +1577,14 @@ weekly update" — and it is proposed on a card. `/standing` and `/orders` are g ## /task — start work you can walk away from +`/task --on ` runs a task in its own copy on a paired computer +running `codeaf serve`, while the chat stays here. Quote shown names containing +spaces: `/task --on "mac mini" Fix the export`. `--best` or `--cheap` may go on +either side of `--on`; `solo` follows the flags. `here` and `this computer` +choose this computer. The flag is offered only where paired tasks are available; +Linux and macOS are supported on both ends, Windows not yet. See *Tasks on +another computer* for placement, the **Run tasks on** default and `run it here`. + `/task ` starts work directly from the words after the command; the brief does not pass through the conversation model. **Nothing is waited for in front of it**: the task exists the moment you press enter, the ordinary started-task row appears diff --git a/internal/manual/chat/how-tasks-run.md b/internal/manual/chat/how-tasks-run.md index 55f775694d..d862e59342 100644 --- a/internal/manual/chat/how-tasks-run.md +++ b/internal/manual/chat/how-tasks-run.md @@ -1217,6 +1217,11 @@ longer evidence that anybody is looking at *that* conversation. ## How long a task gets before it is stopped +For a task on another computer, the default wall is six hours; a time limit +sent by this chat replaces it. The other computer's spending permission still +applies. See *Tasks on another computer*. The checkpoint clocks below describe +local session-tree work. + Two clocks, and neither is a hard stop. **One hour per checkpoint.** The run, every correction round and every check inside it @@ -2656,7 +2661,13 @@ and stay there. ## Does a task survive a restart? -The graph survives. The running work does not. +For a task on another computer, reopening the chat follows the saved attempt +and brings its answer home once. Restarting `codeaf serve` on that computer +follows a child still running there; a child that died is interrupted, never +automatically run again. See *Tasks on another computer* for `run it here`. + +For the local session-tree tasks described below, the graph survives but the +running worker does not. When a session comes back: @@ -3194,7 +3205,18 @@ pointer — they still point at your turn, never at the parent task's own journa ## The optional arguments on propose_task -Four more arguments, all optional. Every bad value is an ordinary result, not an error. +These arguments are optional. Bad values return a refusal the model can correct. + +**`computer`** — the shown name of a paired computer that runs `codeaf serve`. +This argument is present only on a belt with paired-task support; it is absent +where no paired-task connection is available. The task needs its own independent +copy, and both ends need Linux or macOS; Windows is not yet supported. Omitted +uses **Run tasks on**, whose default is `this computer`. An explicit choice wins; +`here` and `this computer` run locally. An unknown name returns +`no paired computer is named "" — your computers are: ` as a tool +error. The chat and transcript stay here. A remote task runs alone in this +conversation; wait for it to finish before proposing more tasks. See *Tasks on +another computer* for the return path, spending and recovery. **`depends_on`** — an array of task ids that must finish first. The task waits for them, and their reports are put in front of it when it starts — every one of them, bounded diff --git a/internal/manual/chat/keys.md b/internal/manual/chat/keys.md index cabfabcec4..fa53d8932e 100644 --- a/internal/manual/chat/keys.md +++ b/internal/manual/chat/keys.md @@ -676,6 +676,10 @@ running. ## Quitting while a task is running — what happens to tasks and background work when the session closes +A task running on another paired computer keeps running there when you close +this chat. Reopening follows its saved attempt and brings its result home once; +it never starts that attempt again automatically. See *Tasks on another computer*. + **A hosted conversation keeps working.** Closing the window, `ctrl+c`, `kill -HUP` and a terminal that went away all detach: the task goes on running in this machine's codeaf service, its questions stay waiting for you, and you rejoin it by opening the same @@ -2728,6 +2732,14 @@ the keyboard, and `esc` there hands it back to the list. ## Keys in the task roster and inside a room +Where paired tasks are available, `ctrl+d computer` on a proposal over an empty +draft cycles `this computer` and eligible paired computers. Accepting sends the +choice shown by `runs on `. When a remote task cannot reach its computer +or was interrupted, its card and page offer `h run it here`; click the card +action or press `h` over an empty draft to start fresh local work. The old +attempt is abandoned and its late result never lands. See *Tasks on another +computer* for limits and the exact recovery sentences. + Opening a task's room gives the keyboard to that room. For a task that accepts notes, type one and press `enter` to send it; you do not need to press `alt+t` again first. Use `alt+t` when you want to navigate the roster again. diff --git a/internal/manual/chat/reaching-this-machine-without-ssh.md b/internal/manual/chat/reaching-this-machine-without-ssh.md index 780853ae64..e86e6ffb04 100644 --- a/internal/manual/chat/reaching-this-machine-without-ssh.md +++ b/internal/manual/chat/reaching-this-machine-without-ssh.md @@ -48,6 +48,15 @@ way, use that; the two doors are compared at the bottom of this page. `codeaf serve` keeps the identity's saved keys and settings in step the way an open terminal does only when it is started with `CODEAF_SERVE_SYNC=1`, which a dev box sets. Without it, serve syncs nothing while it serves, as it always has. +On a paired Linux or macOS computer, `codeaf serve` also prints +`takes tasks from your paired computers`. Those computers can send a task with +`/task --on ` while keeping the chat and transcript +where they are. This task door uses the shared identity and needs no separate +machine-access grant. Only work with its own independent copy can go; Windows +cannot send or take these tasks yet. See *Tasks on another computer*. The +connection name below is for reaching the whole machine, not choosing a task's +computer. + Run it on the machine that owns the work — the one with the files, the API key and the things you have set up: diff --git a/internal/manual/chat/settings.md b/internal/manual/chat/settings.md index 11a457ff4d..fb39acacb5 100644 --- a/internal/manual/chat/settings.md +++ b/internal/manual/chat/settings.md @@ -135,6 +135,14 @@ changed cap to already-open chats. Changing this preference does not stop a running task. An unset task model follows the configured worker, falling back to the current chat model when no worker model is set. +**Run tasks on**, immediately after **Task model** in Tasks, chooses where a +task goes when you do not name a computer. It defaults to `this computer` and +lists eligible paired computers by shown name. It is absent where paired tasks +are unavailable. An explicit `/task --on`, proposal-card choice or `computer` +argument wins. Quick tasks and work without an independent copy still run here. +Both ends need Linux or macOS, and the other computer must run `codeaf serve`. +See *Tasks on another computer* for limits and `run it here`. + ## Connect a service, change models or manage privacy **Connections** contains service sign-in and provider credentials, web search diff --git a/internal/manual/chat/tasks-on-another-computer.md b/internal/manual/chat/tasks-on-another-computer.md new file mode 100644 index 0000000000..e7c24e0434 --- /dev/null +++ b/internal/manual/chat/tasks-on-another-computer.md @@ -0,0 +1,149 @@ +# Tasks on another computer + +## Can a task run on my other computer + +Yes. Pair both computers with the same identity and run `codeaf serve` on the +computer that will do the task. Both ends need Linux or macOS; Windows cannot +send or receive these tasks yet. An ordinary open chat window does not take tasks. +Only a task that works in its own copy can go. + +Choose the computer with `/task --on `, the proposal card's +`ctrl+d computer`, the optional `computer` argument on `propose_task`, or +**Run tasks on** in `/settings` → Tasks. Use its shown device name, not its +connection name. The named choice wins over the setting; the default is +`this computer`. These controls are absent when paired tasks are unavailable. + +The chat, conversation file and transcript stay here. The task's copy goes +encrypted to the other computer, and its change comes home through the ordinary +landing road, including its commits. With private task copies on, use +`/task review work` then `/task apply work` here instead. +The other computer uses its own provider keys; its spend appears here against +the conversation and task, once, and also stays on that computer's ledger. + +## Run a task on my mac mini + +Pair your Mac mini and this computer, then run `codeaf serve` on the mini. +Use its shown name, quoting names that contain spaces: + +``` +/task --on "mac mini" Write the export regression test +/task --cheap --on "mac mini" solo Fix the export +``` + +`--best` or `--cheap` may come before or after `--on`; `solo` follows those +flags. `here` and `this computer` choose this computer. A proposal's +`ctrl+d computer` cycles through `this computer` and eligible paired computers +over an empty draft; accepting the proposal sends that choice. The line says +`runs on `. The model's `computer` argument is available only where the +paired-task belt supports it. A misspelt name is refused with +`no paired computer is named "" — your computers are: `. + +Both computers must run Linux or macOS, and the task needs its own copy. Sending +a task does not move the chat or transcript. The mini uses its own provider keys. + +## What does running on mean + +On a task, `running on ` means its worker runs on that paired +computer while this chat stays here. Its steps appear here in order as it +works. `ran on ` records where completed work ran; +`interrupted on ` means the worker stopped without leaving an answer. +These words appear on task cards, the rail, Tasks rows and the task page. + +The task's change returns to its copy here and follows normal landing. Private +task copies wait for Review and Apply here. Conflicting changes are kept for +your decision. If the task copy here changed while the other computer worked, +codeaf says `the task copy changed here while the other computer worked; both copies were kept`. + +This differs from a whole chat held on another computer: task placement keeps +your conversation and transcript here, and only sends the task's own copy. +The remote worker uses that computer's provider keys; its spend is attributed +to this chat and task on this computer too. + +## My other computer stopped answering + +After about a minute without an answer, the task says +` stopped answering — it may still be working` and offers `run it here`. +The worker may still be running there. codeaf keeps trying; when the connection +returns, the card says `running on ` again and missed steps arrive +in order. Reopening this chat follows the saved attempt and brings its result +home once. Nothing is ever re-run by itself to recover a remote attempt. + +Restarting `codeaf serve` follows a child that is still alive. A child that died +without an answer is interrupted and is never started again automatically. +Stopping the task here asks the other computer to stop; its partial change +comes back through the same landing road as a stopped local task. + +If the computer never took the task, the sentence is +` is not taking tasks right now — run codeaf serve on it`. +An older build says +` runs a different codeaf that cannot take this task — update both`. +A removed device is refused with `this computer was removed from your devices`. + +## Run it here + +When the other computer is unavailable or its task was interrupted, the task +card and task page offer `h run it here`. Click the card action or press `h` +over an empty message box. The action is absent while that computer is still +answering normally; stop the task first in that case. + +Choosing `run it here` abandons the old attempt and starts fresh local work in +the unchanged copy here. A late result from the abandoned attempt never lands. +The placement words disappear and the receipt says +`running task here`. If this copy has changed, codeaf keeps +both copies and refuses to overwrite it. A computer that dropped may still be +working; a request to stop it is best effort. + +## Run tasks on + +In `/settings` → Tasks, **Run tasks on** follows **Task model**. Its hint is +`where a task goes when you do not name a computer`. It offers `this computer` +(the default), then eligible paired computers by shown name. It is absent when +paired tasks cannot work here. A removed or unsupported saved choice displays +`this computer`. Changing the row affects the next task, not work already running. + +An explicit `/task --on`, proposal-card computer choice, or model-supplied +`computer` wins over the setting. Omitted means the setting decides. Quick +tasks and other work without an independent copy run here even when the +setting names another computer, without a special placement sentence. + +## codeaf serve takes tasks from paired computers + +Run `codeaf serve` on the paired computer that should take tasks. It prints +`takes tasks from your paired computers`. Pairing with the same identity is +the permission; no separate machine-access grant is needed for tasks. Quiet or +unpaired homes do not offer this door. Linux and macOS are supported on both +ends; Windows is not yet. Windows serving says +`this computer cannot take tasks from other computers yet`. + +At most two attempts run on that computer at once. A third is refused with +`<computer> is already running two tasks`. If its provider key is missing, the +task ends incomplete with that computer's explanation. The other computer's +keys pay and its spending permission still applies. Without a time limit from +this chat, a remote attempt gets six hours. A time limit sent by this chat +replaces that wall; it cannot lift the other computer's spending permission. +Restarting serve follows +an existing child; it never repeats an admitted attempt's tools. + +## Why did my task run here instead + +Only a task that works in one independent copy can go to another computer. +Quick tasks, program runs, explicit in-place tasks, reference or scratch work, +folders without a transportable copy, and tasks spanning several roots stay +here. A singleton private task copy can go. Linux and macOS are supported on +both ends; Windows is not yet. + +When only **Run tasks on** selected the computer, ineligible work runs here +without special placement text. When you named it explicitly, codeaf explains, +for example: `a quick task runs here — only a task that works in its own copy can run on <computer>`. +A computer that is not serving does not silently redirect work: it offers +`run it here` for your choice. + +## Can this conversation start other tasks while one runs on another computer + +A task on another computer runs alone in this conversation. You can keep +talking here, but wait for that task to finish before starting more tasks, +including another task on the same computer. The refusal is +`the work already underway uses another computer; let it finish before starting this task`. +This limit applies even though the receiving computer can take two attempts +from separate conversations. Remote tasks cannot join more work into the +instruction already sent. diff --git a/internal/manual/chat_test.go b/internal/manual/chat_test.go index 8b8dbf0af8..479d210fbf 100644 --- a/internal/manual/chat_test.go +++ b/internal/manual/chat_test.go @@ -33,6 +33,16 @@ func TestTheChatManualAnswersTheQuestionsPeopleAsk(t *testing.T) { question string page string }{ + // Task placement must answer DESIGN contract 16 in the person's words. + {"can a task run on my other computer", "tasks-on-another-computer"}, + {"run a task on my mac mini", "tasks-on-another-computer"}, + {"what does running on mean", "tasks-on-another-computer"}, + {"my other computer stopped answering", "tasks-on-another-computer"}, + {"run it here", "tasks-on-another-computer"}, + {"run tasks on", "tasks-on-another-computer"}, + {"codeaf serve takes tasks", "tasks-on-another-computer"}, + {"why did my task run here instead", "tasks-on-another-computer"}, + {"can this conversation start other tasks while one runs on another computer", "tasks-on-another-computer"}, {"why didn't my task's changes show up in my folder", "how-tasks-run"}, {"can I run codeaf on my own server", "a-dev-box-on-your-own-railway"}, {"set up a dev box", "a-dev-box-on-your-own-railway"}, diff --git a/internal/session/task_computer_manual_test.go b/internal/session/task_computer_manual_test.go new file mode 100644 index 0000000000..e37252432f --- /dev/null +++ b/internal/session/task_computer_manual_test.go @@ -0,0 +1,39 @@ +package session + +import ( + "strings" + "testing" +) + +// DESIGN 16 requires the rendered guidance and schema to agree for every +// belt shape, while the shared static prompt makes no unconditional promise. +func TestTheManualComputerGuidanceMatchesTheAvailableArgument(t *testing.T) { + if strings.Contains(systemPrompt, "`computer`") { + t.Fatal("the static system prompt promises a conditional argument") + } + for _, shape := range []struct { + name string + paired, inTask, offered bool + }{ + {"paired conversation", true, false, true}, + {"unpaired conversation", false, false, false}, + {"worker without proposals", true, true, false}, + } { + t.Run(shape.name, func(t *testing.T) { + a, _ := newTestAgent(t, &scriptedCompleter{}, func(c *Config) { + c.InTask = shape.inTask + if shape.paired { + c.TaskComputers = testTaskComputers{} + } + }) + argument := false + for _, tool := range a.taskTools() { + _, argument = schemaProperties(t, tool.Schema)["computer"] + } + guidance := strings.Contains(promptWithBeltFacts(a.config), "optional `computer` argument") + if argument != shape.offered || guidance != shape.offered { + t.Fatalf("computer argument=%v, guidance=%v, want both %v", argument, guidance, shape.offered) + } + }) + } +} From a3291b32a0a487eb434969303dc75db6a22ff955 Mon Sep 17 00:00:00 2001 From: Abir Abbas <abirabbas1998@gmail.com> Date: Fri, 30 Oct 2026 22:38:48 -0400 Subject: [PATCH 14/34] tasks: nothing about other computers until a person chooses one With Run tasks on left at this computer, propose_task's schema, the belt facts, the proposal card, a local task's brief and its records are what dev gives, and no roster is read. The computer argument, its belt fact and the card's runs-on line appear only once the setting names a paired computer; /task --on and the card's choice stay the per-task road. A watcher's welcome keeps dev's bytes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- cmd/codeaf/chatv3_taskcomputers.go | 9 +- cmd/codeaf/task_stability_test.go | 206 ++++++++++++ internal/remote/server.go | 7 +- internal/remote/taskcomputers_test.go | 29 ++ internal/session/agent.go | 5 +- internal/session/beltfacts.go | 2 +- internal/session/prompt.go | 12 +- internal/session/session.go | 11 +- internal/session/task.go | 17 +- internal/session/task_computer_manual_test.go | 19 +- internal/session/task_contract.go | 8 +- internal/session/task_elsewhere.go | 6 + internal/session/task_elsewhere_test.go | 17 +- internal/session/task_remote_record.go | 5 + internal/session/task_remote_recovery_test.go | 2 +- internal/session/task_run_belt.go | 4 +- .../session/task_stability_baseline_test.go | 180 ++++++++++ internal/session/task_stability_test.go | 316 ++++++++++++++++++ internal/tui3/app.go | 2 +- internal/tui3/input.go | 3 +- internal/tui3/place_settings.go | 4 +- internal/tui3/settings.go | 5 +- internal/tui3/settings_choice.go | 4 + internal/tui3/task.go | 4 +- internal/tui3/task_stability_test.go | 69 ++++ internal/tui3/taskcomputer.go | 31 +- internal/tui3/taskcomputer_test.go | 14 +- 27 files changed, 939 insertions(+), 52 deletions(-) create mode 100644 cmd/codeaf/task_stability_test.go create mode 100644 internal/session/task_stability_baseline_test.go create mode 100644 internal/session/task_stability_test.go create mode 100644 internal/tui3/task_stability_test.go diff --git a/cmd/codeaf/chatv3_taskcomputers.go b/cmd/codeaf/chatv3_taskcomputers.go index 551930382f..2e7712f137 100644 --- a/cmd/codeaf/chatv3_taskcomputers.go +++ b/cmd/codeaf/chatv3_taskcomputers.go @@ -8,9 +8,9 @@ import ( "github.com/Agent-Field/codeaf/internal/blobstore" "github.com/Agent-Field/codeaf/internal/cellstore" + "github.com/Agent-Field/codeaf/internal/directory" "github.com/Agent-Field/codeaf/internal/home" "github.com/Agent-Field/codeaf/internal/identity" - "github.com/Agent-Field/codeaf/internal/pair" "github.com/Agent-Field/codeaf/internal/reqsign" "github.com/Agent-Field/codeaf/internal/session" "github.com/Agent-Field/codeaf/internal/syncsetup" @@ -33,14 +33,17 @@ func v3TaskComputers(profile string) session.TaskComputers { if !route.On() { return nil } - client, id, err := homeDirectory(profile, pair.Mailbox{URL: route.URL}) + id, err := identity.Load(profile) if err != nil { return nil } device, err := identity.LoadDevice(profile) - if err != nil { + if err != nil || device.Cert.Verify(id.PublicKey()) != nil { return nil } + // This factory is called for every conversation. Only existing keys are + // read here; directory requests belong to an explicit placement choice. + client := directory.NewHTTP(route.URL, reqsign.SignFor(homeSigner{id, device}, time.Now), &http.Client{Timeout: linkTimeout}) engine := cellstore.EngineFor("") engine.DataRoot = filepath.Join(profile, "v3", "stores") engine.Guard.Home = profile diff --git a/cmd/codeaf/task_stability_test.go b/cmd/codeaf/task_stability_test.go new file mode 100644 index 0000000000..ed7ded2dd9 --- /dev/null +++ b/cmd/codeaf/task_stability_test.go @@ -0,0 +1,206 @@ +package main + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "sync/atomic" + "syscall" + "testing" + "time" + + "github.com/Agent-Field/agentfield/sdk/go/ai" + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/provider" + "github.com/Agent-Field/codeaf/internal/relay" + "github.com/Agent-Field/codeaf/internal/session" +) + +// S8 proves the ordinary do entry retains the original store and worker +// defaults even beside a file that would tighten a task door child's limits. +func TestDoWithoutTheHiddenFlagKeepsItsStoreAndControls(t *testing.T) { + profile := beltRunEnv(t) + t.Setenv("CODEAF_PLANDB_BIN", beltPlandbDoor(t)) + t.Setenv("HOME", t.TempDir()) + t.Setenv("CODEAF_TELEMETRY", "off") + t.Setenv("DO_NOT_TRACK", "1") + work := beltRepoWorkspace(t) + if err := os.WriteFile(filepath.Join(filepath.Dir(work), "request.json"), []byte(`{"max_steps":1,"no_progress":1,"effort":"high","limits":{"usd":0.00001}}`), 0600); err != nil { + t.Fatal(err) + } + base := finishingSeat(0.01) + seat := &beltSeat{ever: func(ctx context.Context, messages []ai.Message) (*ai.Response, error) { + if got := provider.ReasoningEffortFrom(ctx); got == provider.EffortHigh { + t.Error("ordinary do inherited remote effort") + } + return base.CompleteWithMessages(ctx, messages) + }} + out, err := runErrand(doRequest{task: "write out.txt", workspace: work, keep: true, timeout: 45 * time.Second, + stderr: &strings.Builder{}, newBeltCompleter: func(string) session.Completer { return seat }}, stubErrandSeats()) + if err != nil || out.status() != 0 { + t.Fatalf("ordinary do was constrained by task controls: %+v, %v", out, err) + } + if !strings.HasPrefix(out.recordKept, filepath.Join(profile, "runs", "codeaf-do-")) { + t.Fatalf("ordinary do left headlessStore: %s", out.recordKept) + } + paths, _ := filepath.Glob(filepath.Join(out.recordKept, "tasks", "*", "trajectory.jsonl")) + if len(paths) == 0 { + t.Fatal("the normal kept run has no trajectory") + } + for _, path := range paths { + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + for _, encoded := range strings.Split(strings.TrimSpace(string(raw)), "\n") { + var line map[string]json.RawMessage + if err := json.Unmarshal([]byte(encoded), &line); err != nil { + t.Fatal(err) + } + if line["recorded_order"] != nil || line["recorded_at"] != nil { + t.Fatalf("ordinary do wrote task-only ordering: %s", encoded) + } + } + } +} + +// S1 includes building v3Shape for every conversation, including linked windows. +func TestBuildingAPairedConversationDialsNothing(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("paired tasks require Linux or macOS") + } + profile := t.TempDir() + t.Setenv("HOME", t.TempDir()) + t.Setenv("CODEAF_HOME", profile) + t.Setenv("CODEAF_PROFILE_DIR", "") + t.Setenv("CODEAF_TELEMETRY", "off") + t.Setenv("DO_NOT_TRACK", "1") + var calls atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + http.NotFound(w, r) + })) + defer server.Close() + t.Setenv("CODEAF_SYNC_URL", server.URL) + if _, err := identity.Ensure(profile); err != nil { + t.Fatal(err) + } + for _, lanes := range []v3Lanes{v3LanesHere(), v3LanesOverWire()} { + cfg, _ := v3Shape(session.Config{}, lanes) + if cfg.TaskComputers == nil { + t.Fatal("paired home lost the person's placement door") + } + } + if got := calls.Load(); got != 0 { + t.Fatalf("building the conversation made %d relay calls", got) + } +} + +// S1 requires construction to read local identity material without repairing +// or creating it. A missing device key cannot opt a home into task placement. +func TestBuildingATaskDoorNeverCreatesAMissingDevice(t *testing.T) { + profile := pairedTaskDefaultHome(t) + // The identity API owns the paths; removing its device file models an + // interrupted setup without creating a second identity. + devicePath := filepath.Join(profile, identity.DeviceFile) + if err := os.Remove(devicePath); err != nil { + t.Fatal(err) + } + if computers := v3TaskComputers(profile); computers != nil { + t.Fatal("construction repaired a missing device") + } + if _, err := os.Stat(devicePath); !os.IsNotExist(err) { + t.Fatalf("construction created a device file: %v", err) + } +} + +// S5 exercises the real command while preserving the test process's signals. +func TestPlainServeHelper(t *testing.T) { + if os.Getenv("CODEAF_TEST_PLAIN_SERVE") != "1" { + return + } + os.Setenv("HOME", os.Getenv("CODEAF_TEST_SERVE_HOME")) + os.Setenv("CODEAF_HOME", os.Getenv("CODEAF_TEST_SERVE_HOME")) + os.Setenv("CODEAF_PROFILE_DIR", "") + os.Setenv("CODEAF_SYNC_URL", os.Getenv("CODEAF_TEST_SERVE_RELAY")) + err := runServe([]string{"--via", os.Getenv("CODEAF_TEST_SERVE_RELAY")}) + if err != nil { + t.Fatal(err) + } +} + +// S5 keeps plain serve's sole registration and output on the machine door. +func TestPlainServeOpensNoTaskDoor(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("the fixture stops serve with a Unix signal") + } + profile := t.TempDir() + if _, err := identity.Ensure(profile); err != nil { + t.Fatal(err) + } + var registrations atomic.Int32 + machine := &relay.Server{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/v1/engine" { + registrations.Add(1) + } + machine.ServeHTTP(w, r) + })) + defer server.Close() + defer server.CloseClientConnections() + log := filepath.Join(profile, "serve.log") + output, err := os.Create(log) + if err != nil { + t.Fatal(err) + } + defer output.Close() + cmd := exec.Command(os.Args[0], "-test.run=^TestPlainServeHelper$") + cmd.Env = append(os.Environ(), "CODEAF_TEST_PLAIN_SERVE=1", "CODEAF_TEST_SERVE_HOME="+profile, "CODEAF_TEST_SERVE_RELAY="+server.URL, "CODEAF_HOME="+filepath.Join(profile, "bootstrap"), "HOME="+filepath.Join(profile, "bootstrap"), "CODEAF_TELEMETRY=off", "DO_NOT_TRACK=1") + cmd.Stdout, cmd.Stderr = output, output + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + defer cmd.Process.Kill() + deadline := time.Now().Add(10 * time.Second) + for { + raw, _ := os.ReadFile(log) + if strings.Contains(string(raw), "this machine is reachable as") { + break + } + if time.Now().After(deadline) { + t.Fatalf("serve did not register: %s", raw) + } + time.Sleep(10 * time.Millisecond) + } + if err := cmd.Process.Signal(syscall.SIGTERM); err != nil { + t.Fatal(err) + } + if err := cmd.Wait(); err != nil { + raw, _ := os.ReadFile(log) + t.Fatalf("serve: %v\n%s", err, raw) + } + raw, _ := os.ReadFile(log) + if registrations.Load() != 1 || strings.Contains(string(raw), "tasks") { + t.Fatalf("plain serve added a task door: registrations=%d\n%s", registrations.Load(), raw) + } + if _, err := os.Stat(filepath.Join(profile, "v3", "taskdoor")); !os.IsNotExist(err) { + t.Fatalf("plain serve touched task storage: %v", err) + } +} + +// S5 makes receiving tasks an explicit, discoverable command choice. +func TestServeHelpOffersTakeTasks(t *testing.T) { + out, _ := captureUsage(t) + if code := exitCodeOf(runServe([]string{"--help"})); code != 0 { + t.Fatalf("serve help exit=%d", code) + } + if !strings.Contains(out.String(), "--take-tasks") { + t.Fatalf("serve help hides the opt-in: %s", out.String()) + } +} diff --git a/internal/remote/server.go b/internal/remote/server.go index 5e8eebb45a..1cde1e9903 100644 --- a/internal/remote/server.go +++ b/internal/remote/server.go @@ -1094,9 +1094,6 @@ func (sess *Session) attach(s *server, hello Hello) error { // names the surfaces that have already drawn it (held.go). s.name = machineLabel(hello.Surface) s.clientID = strings.TrimSpace(hello.ClientID) - // Read-only controls must already be absent from the initial welcome; - // recording this after it was built advertised controls a watcher cannot use. - s.watching = hello.Watch // THE ARRIVAL IS AN ACT. A window that has just been welcomed is // watching, and a pipe that tears on the way in — or a getter that // no longer crosses the wire — must not read as nobody having been @@ -1110,6 +1107,9 @@ func (sess *Session) attach(s *server, hello Hello) error { // Attached counts the OTHERS, so it is read before this one is added // (wire.go's Welcome.Attached states why the number is carried at all). welcome := sess.welcomeLocked(s) + // The initial capability reads the arrival directly, preserving the machine's + // existing point at which a watcher becomes registered. + welcome.TaskComputers = !hello.Watch && sess.engine.SessionFile != "" && taskComputersKnown(sess.agent) welcome.Encoding = s.encoding welcome.Attached = len(sess.surfaces) sess.surfaces[s] = struct{}{} @@ -1119,6 +1119,7 @@ func (sess *Session) attach(s *server, hello Hello) error { // new one (driver.go states the whole rule, [Hello.Back] states why the // difference is load-bearing). A WATCHER IS NEITHER: it never drives, and the // flag is recorded so no later hand-on can give it the keyboard either. + s.watching = hello.Watch if hello.Join { // WHAT THIS CONNECTION IS BOUND TO is the conversation it named, confirmed // against the one open under this same lock a few lines above. The engine's diff --git a/internal/remote/taskcomputers_test.go b/internal/remote/taskcomputers_test.go index e665b58ad8..13fa9436f7 100644 --- a/internal/remote/taskcomputers_test.go +++ b/internal/remote/taskcomputers_test.go @@ -1,6 +1,7 @@ package remote import ( + "bytes" "context" "encoding/json" "strings" @@ -10,6 +11,34 @@ import ( "github.com/Agent-Field/codeaf/internal/session" ) +// S9 keeps the watcher's welcome bytes unchanged when task controls are absent. +func TestAWatcherWelcomeIsUnchanged(t *testing.T) { + taskComputerWireHome(t) + file := t.TempDir() + "/chat.jsonl" + welcome := func(paired bool) []byte { + far := &pairedTaskWireAgent{fakeAgent: &fakeAgent{}, available: paired} + loop, err := Loopback(Hello{Version: Version, Watch: true}, Options{Boot: func(Hello) (*Engine, error) { + return &Engine{Agent: far, SessionFile: file}, nil + }}) + if err != nil { + t.Fatal(err) + } + defer loop.Close() + welcome := loop.Client.Welcome() + // Each live owner mints its own cursor scope, independently of capabilities. + welcome.SessionInstance = "" + raw, err := json.Marshal(welcome) + if err != nil { + t.Fatal(err) + } + return raw + } + original, paired := welcome(false), welcome(true) + if !bytes.Equal(original, paired) || bytes.Contains(paired, []byte(`"taskComputers"`)) { + t.Fatalf("a watcher's welcome changed:\n%s\n%s", original, paired) + } +} + type pairedTaskWireAgent struct { *fakeAgent computerMu sync.Mutex diff --git a/internal/session/agent.go b/internal/session/agent.go index 1ae0b62e5c..9b16d463db 100644 --- a/internal/session/agent.go +++ b/internal/session/agent.go @@ -143,8 +143,9 @@ func newAgent(config Config, client Completer) (*Agent, error) { watch.TrackTurn(config.Place.Dir) } agent := &Agent{ - config: config, - system: system, + taskComputerPrompt: config.mayNameTaskComputer(), + config: config, + system: system, // STAMPED ONLY WHERE WE RENDERED IT. A prompt handed in by a caller is // theirs, and re-rendering ours over the top of it later would be this // file deciding what another door's worker is told. diff --git a/internal/session/beltfacts.go b/internal/session/beltfacts.go index f2a545c24a..3fea8aa033 100644 --- a/internal/session/beltfacts.go +++ b/internal/session/beltfacts.go @@ -779,7 +779,7 @@ func promptWithBeltFacts(config Config) string { for strings.Contains(page, "\n\n\n") { page = strings.ReplaceAll(page, "\n\n\n", "\n\n") } - if config.TaskComputers != nil && config.mayProposeTask() { + if config.mayNameTaskComputer() && config.mayProposeTask() { page += "\n\nA task that works in its own copy can run on a paired computer. The optional `computer` argument on `propose_task` names its shown name; omitted uses Run tasks on, whose default is this computer. A remote task runs alone; wait for it to finish before proposing more work." } return page diff --git a/internal/session/prompt.go b/internal/session/prompt.go index 8c5f4e96d4..608f91d3a8 100644 --- a/internal/session/prompt.go +++ b/internal/session/prompt.go @@ -548,7 +548,10 @@ func nowLine(now time.Time) string { // // It is called with a.mu held, from [Agent.startTurnLocked]. func (a *Agent) refreshClockLocked(now time.Time) { - if now.Sub(a.systemAt) < clockRefresh { + // A placement choice changes the available argument immediately, so its + // promise cannot wait for the clock or survive a reset to this computer. + placementChanged := a.systemOwn && a.config.TaskComputers != nil && a.taskComputerPrompt != a.config.mayNameTaskComputer() + if now.Sub(a.systemAt) < clockRefresh && !placementChanged { return } a.rerenderSystemLocked(now) @@ -557,10 +560,10 @@ func (a *Agent) refreshClockLocked(now time.Time) { // rerenderSystemLocked rebuilds this agent's instructions from its config AS IT // NOW STANDS, and puts them back at the head of the transcript. // -// IT IS ONE FUNCTION BECAUSE THE CONFIG MOVES UNDER RUNNING AGENTS, in three -// ways and no more: the clock going stale above, a conversation anchoring to a +// IT IS ONE FUNCTION BECAUSE THE CONFIG MOVES UNDER RUNNING AGENTS: the +// clock or task-placement preference changes above, a conversation anchors to a // project (tools_anchor_workspace.go), and work being armed to divide beside its -// worker (task_divide.go's [Agent.armDivisionBeside]). Each of the three changes +// worker (task_divide.go's [Agent.armDivisionBeside]). Each change // something [renderSystemAt] reads, and a prompt assembled in two different ways // is a prompt that will one day disagree with itself — which is the same reason // the clock re-renders the whole footer rather than editing one line. @@ -575,6 +578,7 @@ func (a *Agent) rerenderSystemLocked(now time.Time) { return } a.system = renderSystemAt(a.liveModelConfigLocked(), now) + a.taskComputerPrompt = a.config.mayNameTaskComputer() a.systemAt = now a.refreshSystemLocked() } diff --git a/internal/session/session.go b/internal/session/session.go index 8eebe9c61b..b5958f9efb 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -2413,11 +2413,12 @@ type Config struct { // events — every Submit streams, whether it started the turn or steered it. // The methods live in agent.go; the loop they drive lives in loop.go. type Agent struct { - remoteAckJobs sync.WaitGroup - knowledgeMu sync.RWMutex - knowledgeRetired []*knowledgeJournal - knowledgeVault *knowledge.Vault - knowledgeJournal *knowledgeJournal + taskComputerPrompt bool + remoteAckJobs sync.WaitGroup + knowledgeMu sync.RWMutex + knowledgeRetired []*knowledgeJournal + knowledgeVault *knowledge.Vault + knowledgeJournal *knowledgeJournal chatStarts map[string]bool // jailNotice keeps a host limit from becoming noise on every tool call. diff --git a/internal/session/task.go b/internal/session/task.go index a85022ba1a..a3be8ad1c2 100644 --- a/internal/session/task.go +++ b/internal/session/task.go @@ -608,7 +608,7 @@ func (a *Agent) taskTools() []bare.Tool { if a.config.mayDelegate() { schema = taskSchemaJSON } - if a.config.TaskComputers != nil { + if a.config.mayNameTaskComputer() { schema = strings.Replace(schema, `"properties":{`, `"properties":{`+taskComputerSchema, 1) } if WorkspaceOwnershipEnabled(a.config.Place) { @@ -710,6 +710,12 @@ func proposalProblems(contract string, stand taskStand) string { // started early (the quality law of an early start: the same brief, sooner). func (a *Agent) stageTask(ctx context.Context, args json.RawMessage) bare.Staged { spec, problem := parseTaskArguments(args) + // A model cannot opt into placement through an argument its belt did not + // offer. Preserve the original treatment of that unknown field; a person + // can still choose a computer in the later proposal answer. + if !a.config.mayNameTaskComputer() { + spec.computer = "" + } if problem != "" { // A CONTRACT PROBLEM DOES NOT HIDE A PLACEMENT PROBLEM. Once the call has // enough of a task to place, both readings are already available and each @@ -954,11 +960,7 @@ func proposalBrief(spec taskSpec, via *delegate.Delegate) string { if via != nil && via.Words { return programWords(spec.brief, *via) } - brief := composeBrief(briefWhole, spec.request, spec.brief, spec.deliverable, spec.acceptance, "", spec.admission, spec.origin, taskCopy{}) - if len(spec.checks) > 0 { - brief += "\n\nChecks\n\n" + strings.Join(spec.checks, "\n") - } - return brief + return composeBrief(briefWhole, spec.request, spec.brief, spec.deliverable, spec.acceptance, "", spec.admission, spec.origin, taskCopy{}) } // commitProposalToRun is the run road of an approved proposal: an approved @@ -1016,6 +1018,9 @@ func (a *Agent) commitProposalToRun(ctx context.Context, p *stagedProposal, spec ctx = withTaskPlacement(ctx, placement) elsewhere = withReport(elsewhere, placement.sentence) description := proposalBrief(spec, via) + if placement.computer != nil && len(spec.checks) > 0 { + description += "\n\nChecks\n\n" + strings.Join(spec.checks, "\n") + } // THE RUN OUTLIVES THE TURN THAT LAUNCHED IT, AND NOT THE CONVERSATION. // This context is the turn's, and the turn cancels it on its way out // (agent.go, `defer cancel(nil)`); a run driven under it would be stopped diff --git a/internal/session/task_computer_manual_test.go b/internal/session/task_computer_manual_test.go index e37252432f..9405a95be6 100644 --- a/internal/session/task_computer_manual_test.go +++ b/internal/session/task_computer_manual_test.go @@ -3,6 +3,8 @@ package session import ( "strings" "testing" + + "github.com/Agent-Field/codeaf/internal/config" ) // DESIGN 16 requires the rendered guidance and schema to agree for every @@ -12,20 +14,27 @@ func TestTheManualComputerGuidanceMatchesTheAvailableArgument(t *testing.T) { t.Fatal("the static system prompt promises a conditional argument") } for _, shape := range []struct { - name string - paired, inTask, offered bool + name string + paired, preferred, inTask, offered bool }{ - {"paired conversation", true, false, true}, - {"unpaired conversation", false, false, false}, - {"worker without proposals", true, true, false}, + {"paired default conversation", true, false, false, false}, + {"paired chosen computer", true, true, false, true}, + {"unpaired conversation", false, false, false, false}, + {"worker without proposals", true, true, true, false}, } { t.Run(shape.name, func(t *testing.T) { a, _ := newTestAgent(t, &scriptedCompleter{}, func(c *Config) { c.InTask = shape.inTask + c.ProfileDir = t.TempDir() if shape.paired { c.TaskComputers = testTaskComputers{} } }) + if shape.preferred { + if err := config.WriteTaskRunsOn(a.config.ProfileDir, "dev_0123456789abcdef0123456789abcdef"); err != nil { + t.Fatal(err) + } + } argument := false for _, tool := range a.taskTools() { _, argument = schemaProperties(t, tool.Schema)["computer"] diff --git a/internal/session/task_contract.go b/internal/session/task_contract.go index bc0606bbb0..7160117878 100644 --- a/internal/session/task_contract.go +++ b/internal/session/task_contract.go @@ -452,10 +452,10 @@ func settleOrAsk(word string) TaskSettle { // did not set. type TaskNotice struct { // Placement is separate from the state word: empty Computer means here. - Computer string - Unreachable bool - RanElsewhere bool - Remote *TaskRemoteRecord + Computer string `json:",omitempty"` + Unreachable bool `json:",omitempty"` + RanElsewhere bool `json:",omitempty"` + Remote *TaskRemoteRecord `json:",omitempty"` // Thinking is the effective setup, including a saved continuation choice. Thinking string // ID is the proposal's token: a surface hands it back to diff --git a/internal/session/task_elsewhere.go b/internal/session/task_elsewhere.go index d34deeacdd..c98f636726 100644 --- a/internal/session/task_elsewhere.go +++ b/internal/session/task_elsewhere.go @@ -219,3 +219,9 @@ func taskCopyCanTravel(stand taskStand) bool { } return stand.mode == TaskModeWorktree } + +// Pairing supplies the person's placement door. Only a saved remote preference +// adds placement to the model's vocabulary. Both reads are local. +func (c Config) mayNameTaskComputer() bool { + return c.TaskComputers != nil && config.TaskRunsOnAt(c.ProfileDir) != "" +} diff --git a/internal/session/task_elsewhere_test.go b/internal/session/task_elsewhere_test.go index 90ea7f9222..42403b45e1 100644 --- a/internal/session/task_elsewhere_test.go +++ b/internal/session/task_elsewhere_test.go @@ -15,7 +15,13 @@ import ( // Contract 6 makes a misspelt computer repairable at the tool boundary. func TestAnUnknownComputerNameIsAToolErrorListingTheComputers(t *testing.T) { - a, _ := newTestAgent(t, &scriptedCompleter{}, func(c *Config) { c.TaskComputers = testTaskComputers{} }) + a, _ := newTestAgent(t, &scriptedCompleter{}, func(c *Config) { + c.ProfileDir = t.TempDir() + c.TaskComputers = testTaskComputers{} + if err := config.WriteTaskRunsOn(c.ProfileDir, "dev_0123456789abcdef0123456789abcdef"); err != nil { + t.Fatal(err) + } + }) result, refused, err := a.stageTask(context.Background(), json.RawMessage(`{"title":"Repair export","summary":"Repair the export path","brief":"Fix the export failure in the project","deliverable":"working export","acceptance":"export succeeds","computer":"missing"}`)).Commit(context.Background()) if err != nil || !refused || !strings.Contains(result, `no paired computer is named "missing"`) || !strings.Contains(result, "work computer") { t.Fatalf("tool result = %q, %v, %v", result, refused, err) @@ -164,6 +170,9 @@ func TestAProposalNamingAPairedComputerRunsThere(t *testing.T) { return RunSummary{Outcome: "done", Result: "done there"} }} a, local := remoteTaskAgent(t, remote, func(c *Config) { + if err := config.WriteTaskRunsOn(c.ProfileDir, "dev_0123456789abcdef0123456789abcdef"); err != nil { + t.Fatal(err) + } c.RouteCrew = func(config.CrewAsk) (crewroute.Decision, error) { t.Error("remote work consulted the origin's crew") return crewroute.Decision{}, errors.New("origin crew must stay unused") @@ -312,7 +321,11 @@ func TestAnExplicitInPlaceTaskRunsHereEvenWhenAComputerWasNamed(t *testing.T) { _ = spec.Store.CompleteRoot("wrong computer") return RunSummary{Outcome: "done"} }} - a, local := remoteTaskAgent(t, remote, nil) + a, local := remoteTaskAgent(t, remote, func(c *Config) { + if err := config.WriteTaskRunsOn(c.ProfileDir, "dev_0123456789abcdef0123456789abcdef"); err != nil { + t.Fatal(err) + } + }) args := taskOnOtherComputer() args.Where = "in place" args.Deliverable = "a corrected " + filepath.Join(a.config.Workspace, "output.txt") diff --git a/internal/session/task_remote_record.go b/internal/session/task_remote_record.go index 4063db714a..22a9fa5669 100644 --- a/internal/session/task_remote_record.go +++ b/internal/session/task_remote_record.go @@ -62,6 +62,11 @@ func atomicRemoteFile(path string, data []byte) error { return directory.Sync() } func readRemoteRecord(store *plandb.Store, fallback *TaskRemoteRecord) *TaskRemoteRecord { + // A local row is authoritative about being local. A stray task-door file + // must not change recovery or cause any new I/O for an ordinary run. + if fallback == nil || fallback.Executor.Device == "" { + return nil + } raw, err := os.ReadFile(taskRemotePath(store)) if err != nil { return cloneTaskRemote(fallback) diff --git a/internal/session/task_remote_recovery_test.go b/internal/session/task_remote_recovery_test.go index 8b266405f5..45446926ce 100644 --- a/internal/session/task_remote_recovery_test.go +++ b/internal/session/task_remote_recovery_test.go @@ -278,7 +278,7 @@ func TestARefusedRemoteStartWaitsForRunItHere(t *testing.T) { r := remoteRecordFor(spec) r.StartFailed = true _ = spec.SaveRemote(r, true) - return RunSummary{Outcome: "incomplete", Result: "work computer is not taking tasks right now — run codeaf serve on it", KeepCopy: true} + return RunSummary{Outcome: "incomplete", Result: "work computer is not taking tasks right now — run codeaf serve --take-tasks on it", KeepCopy: true} }} a, local := remoteTaskAgent(t, remote, nil) if _, _, _, err := a.StartTaskOn(context.Background(), "Repair export", false, "work computer"); err != nil { diff --git a/internal/session/task_run_belt.go b/internal/session/task_run_belt.go index 2f84a759a6..082047bb6b 100644 --- a/internal/session/task_run_belt.go +++ b/internal/session/task_run_belt.go @@ -2605,10 +2605,10 @@ func (a *Agent) beltRunNotice(run *beltRun, summary RunSummary, landing RunLandi } report += line } - if summary.NeedsDecision { + if run.remoteEngine != nil && summary.NeedsDecision { state = TaskUnverified } - if summary.Outcome == "interrupted" { + if run.remoteEngine != nil && summary.Outcome == "interrupted" { state = TaskInterrupted } notice := TaskNotice{ diff --git a/internal/session/task_stability_baseline_test.go b/internal/session/task_stability_baseline_test.go new file mode 100644 index 0000000000..b61bfc7f9b --- /dev/null +++ b/internal/session/task_stability_baseline_test.go @@ -0,0 +1,180 @@ +package session + +import ( + "bytes" + "encoding/json" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/crewroute" + "github.com/Agent-Field/codeaf/internal/provider" + "github.com/Agent-Field/codeaf/internal/taskcopy" +) + +// These frozen record schemas come from origin/dev before task placement. +// Comparing real saved bytes against them catches added fields and reordered +// fields without replacing the runtime values (paths, identities or clocks). +type originalTaskDocument struct { + WorkspaceTransfer *taskcopy.WorkspacePlan `json:"workspace_transfer,omitempty"` + Type string `json:"type"` + Version int `json:"version"` + Seq uint64 `json:"seq"` + Nodes []taskRecord `json:"nodes"` + Runs []originalRunRecord `json:"runs,omitempty"` +} + +type originalRunRecord struct { + RecoveredCompletion bool `json:"recoveredCompletion,omitempty"` + CrewState *TaskCrewRecord `json:"crewState,omitempty"` + ID uint64 `json:"id"` + Run string `json:"run,omitempty"` + Node string `json:"node,omitempty"` + Parent uint64 `json:"parent,omitempty"` + Title string `json:"title,omitempty"` + Kind TaskKind `json:"kind,omitempty"` + Program string `json:"program,omitempty"` + State TaskState `json:"state"` + Stopped bool `json:"stopped,omitempty"` + Report string `json:"report,omitempty"` + Model string `json:"model,omitempty"` + CostUSD float64 `json:"costUsd,omitempty"` + StartedAt time.Time `json:"startedAt,omitzero"` + EndedAt time.Time `json:"endedAt,omitzero"` + WorkspaceSet *WorkspaceSet `json:"workspaceSet,omitempty"` + Copy *TaskCopyRecord `json:"copy,omitempty"` + PendingRun *PendingRunRecord `json:"pendingRun,omitempty"` + PlanTask string `json:"planTask,omitempty"` + ElapsedMS int64 `json:"elapsed_ms,omitempty"` + Ending TaskEnding `json:"ending,omitempty"` + Branch string `json:"branch,omitempty"` + Merge string `json:"merge,omitempty"` + Result string `json:"result,omitempty"` + Changed []string `json:"changed,omitempty"` +} + +type originalTaskNotice struct { + Thinking string + ID uint64 + Run string + Node string + Title string + Kind TaskKind + Program string + Ceiling string + Summary string + Brief string + Acceptance string + Where string + WorkspaceURI, ChatURI, ProjectURI, GroundURI, RootURI string + Ground string + Mode TaskMode + Rung GroundRung + DependsOn []uint64 + Parent uint64 + Deadline time.Time + Withdrawn string + Decided *TaskAnswer + ModelOptions []string + Elsewhere string + State TaskState + Elapsed time.Duration + StartedAt time.Time + EndedAt time.Time + Report string + Result string + ResultWhole string + ResultCut bool + ResultHeld bool + Changed []string + Branch string + Copy *TaskCopyRecord + WorkspaceSet *WorkspaceSet + RecoveredCompletion bool + PendingRun *PendingRunRecord + PlanTask string + Merge string + Doing string + Context string + Mending string + Waiting string + Paused bool + Settling string + Stopped bool + Ending TaskEnding + Conflicts []string + Shifted bool + GroundHeld bool + Decider TaskAskOwner + Checked provider.Reading + Model string + NextModel string + Crew *crewroute.Decision + CrewState *TaskCrewRecord `json:"-"` + CostUSD float64 + Tokens int +} + +type originalUsageLine struct { + At time.Time `json:"at"` + Day string `json:"day"` + Model string `json:"model,omitempty"` + Role string `json:"role,omitempty"` + Seat Seat `json:"seat,omitempty"` + Calls int `json:"calls,omitempty"` + Input int `json:"in,omitempty"` + Output int `json:"out,omitempty"` + USD float64 `json:"usd,omitempty"` + Reconciled bool `json:"reconciled,omitempty"` + Unbilled bool `json:"unbilled,omitempty"` + Empty bool `json:"empty,omitempty"` + Session string `json:"session,omitempty"` + Task string `json:"task,omitempty"` + Root string `json:"root,omitempty"` + Standing string `json:"standing,omitempty"` + Automation string `json:"automation,omitempty"` + Workspace string `json:"workspace,omitempty"` + Lane string `json:"lane,omitempty"` + TTFTms int64 `json:"ttft_ms,omitempty"` + TPS float64 `json:"tps,omitempty"` + Hedged bool `json:"hedged,omitempty"` + HedgeWasteUSD float64 `json:"hedge_waste_usd,omitempty"` +} + +type originalJournalUsage struct { + Model string `json:"model,omitempty"` + Input int `json:"input,omitempty"` + Output int `json:"output,omitempty"` + CacheRead int `json:"cacheRead,omitempty"` + CacheWrite int `json:"cacheWrite,omitempty"` + CostUSD float64 `json:"costUsd,omitempty"` + Calls int `json:"calls,omitempty"` + DurationMS int64 `json:"durationMs,omitempty"` + Aux bool `json:"aux,omitempty"` + Empty bool `json:"empty,omitempty"` + Role string `json:"role,omitempty"` +} + +// The schema is the historical fixture, while raw is the actual persisted +// record. Unknown fields and different ordering therefore fail byte equality. +func assertOriginalLocalJSON(t *testing.T, raw []byte, original any, indented bool) { + t.Helper() + if err := json.Unmarshal(raw, original); err != nil { + t.Fatal(err) + } + var expected []byte + var err error + if indented { + expected, err = json.MarshalIndent(original, "", " ") + } else { + expected, err = json.Marshal(original) + } + if err != nil { + t.Fatal(err) + } + if indented { + expected = append(expected, '\n') + } + if !bytes.Equal(raw, expected) { + t.Fatalf("local record changed from the original schema:\n%s\nwant:\n%s", raw, expected) + } +} diff --git a/internal/session/task_stability_test.go b/internal/session/task_stability_test.go new file mode 100644 index 0000000000..b8c22a95ae --- /dev/null +++ b/internal/session/task_stability_test.go @@ -0,0 +1,316 @@ +package session + +import ( + "bytes" + "context" + "encoding/json" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/plandb" +) + +type countedTaskComputers struct { + testTaskComputers + lists, engines int +} + +func (c *countedTaskComputers) List(ctx context.Context) ([]TaskComputer, error) { + c.lists++ + return c.testTaskComputers.List(ctx) +} + +func (c *countedTaskComputers) Engine(ctx context.Context, device string) (TaskRunEngine, error) { + c.engines++ + return c.testTaskComputers.Engine(ctx, device) +} + +// S1 and S4 exercise proposal admission, a local join and normal landing with +// a paired home. There must be no placement I/O or remote spec/record fields. +func TestALocalTaskKeepsItsRunLandingAndRecords(t *testing.T) { + computers := &countedTaskComputers{} + a, local := remoteTaskAgent(t, nil, func(c *Config) { c.TaskComputers = computers }) + args := taskOnOtherComputer() + args.Computer = "" + if result, refused := approveRemoteProposal(t, a, args); refused { + t.Fatal(result) + } + <-local.entered + local.mu.Lock() + spec := local.spec + local.mu.Unlock() + if spec.Remote != nil || spec.SaveRemote != nil || spec.WithRemoteCopy != nil || spec.OnRemoteStep != nil || spec.OnRemoteCharge != nil || spec.OnRemotePhase != nil || spec.RemoteStopped != nil || spec.TaskNumber != 0 || spec.NamedModel != "" || spec.NamedEffort != "" || spec.NoProgress != 0 { + t.Fatal("an ordinary run received remote controls") + } + if strings.Contains(spec.Store.Task(spec.Store.RootID()).Description, "\n\nChecks\n") { + t.Fatal("a local plan row gained the remote Checks section") + } + if result, refused := approveRemoteProposal(t, a, args); refused || !strings.Contains(result, "joined") { + t.Fatalf("local join changed: %q, refused=%v", result, refused) + } + close(local.release) + waitRemoteRun(t, a) + if local.lands() == 0 || computers.lists != 0 || computers.engines != 0 { + t.Fatalf("landing=%d, directory=%d, engines=%d", local.lands(), computers.lists, computers.engines) + } + if records, _ := filepath.Glob(filepath.Join(filepath.Dir(spec.Store.Path()), "tasks", "*", "remote.json")); len(records) != 0 { + t.Fatalf("local tasks wrote remote sidecars: %v", records) + } + a.SettleWrites() + checkpoint, err := os.ReadFile(a.config.checkpointFile()) + if err != nil { + t.Fatal(err) + } + assertOriginalLocalJSON(t, checkpoint, &originalTaskDocument{}, true) + for _, row := range a.graph().runRows(1) { + raw, err := json.Marshal(row) + if err != nil { + t.Fatal(err) + } + assertOriginalLocalJSON(t, raw, &originalTaskNotice{}, false) + } + // The real local billing door must retain the original per-call schema. + a.config.usageLedger = filepath.Join(t.TempDir(), "usage.jsonl") + if err := a.recordUsageLine(bankedCall{model: "test/model", used: Usage{Input: 7, Output: 3, CostUSD: .25, Calls: 1}}); err != nil { + t.Fatal(err) + } + if !FlushUsage() { + t.Fatal("local usage did not reach disk") + } + ledger, err := os.ReadFile(a.config.usageLedger) + if err != nil { + t.Fatal(err) + } + for _, line := range bytes.Split(bytes.TrimSpace(ledger), []byte("\n")) { + assertOriginalLocalJSON(t, line, &originalUsageLine{}, false) + } + a.file.appendUsage(Usage{Input: 7, Output: 3, CostUSD: .25, Calls: 1}, "test/model", true, "") + journal, err := os.ReadFile(a.config.SessionFile) + if err != nil { + t.Fatal(err) + } + usageRows := 0 + for _, line := range bytes.Split(bytes.TrimSpace(journal), []byte("\n")) { + var entry struct { + Type string `json:"type"` + Usage json.RawMessage `json:"usage"` + } + if err := json.Unmarshal(line, &entry); err != nil { + t.Fatal(err) + } + if entry.Type == "usage" { + assertOriginalLocalJSON(t, entry.Usage, &originalJournalUsage{}, false) + usageRows++ + } + } + if usageRows == 0 { + t.Fatal("the local journal recorded no usage") + } +} + +// S4 keeps a paired local run's copy on recovery, stops it through the normal +// cancellation road, and never starts stopped work again on a later reopen. +func TestALocalTaskStopRecoveryAndReopenUseTheOriginalRoad(t *testing.T) { + t.Setenv("CODEAF_TASK_BELT", "bash") + local := newBeltRunDouble("done here") + local.honoursStop = true + registerBeltRunEngine(t, local) + computers := &countedTaskComputers{} + place, repo, profile := t.TempDir(), beltRunCommittedRepo(t), t.TempDir() + open := func() *Agent { + a, _ := newTestAgent(t, beltRunCompleter{text: "done"}, func(c *Config) { + c.Workspace, c.Place, c.SessionFile = repo, Place{Dir: place}, filepath.Join(place, placeTranscript) + c.ProfileDir, c.TaskComputers, c.AskConsent = profile, computers, false + }) + return a + } + first := open() + g := first.graph() + id := g.reserve() + tree, err := prepareTaskTree(first.config.Place, repo, "1111bbbb1111bbbb", id, "repair export") + if err != nil { + t.Fatal(err) + } + sentinel := filepath.Join(tree.dir, "partial.txt") + if err := os.WriteFile(sentinel, []byte("keep this"), 0600); err != nil { + t.Fatal(err) + } + store, err := plandb.Open(g.planPath(), "repair export", strconv.FormatUint(id, 10), "repair export", "original local brief") + if err != nil { + t.Fatal(err) + } + first.publishRunRow(g, TaskNotice{ID: id, Title: "repair export", State: TaskRunning, Copy: runCopyOf(tree), CrewState: first.unroutedCrewRecord(), PlanTask: planStoreID(strconv.FormatUint(id, 10))}) + // A stray sidecar may not turn a checkpoint without an executor into remote work. + if err := writeRemoteRecord(store, TaskRemoteRecord{Executor: TaskExecutor{Device: "dev_0123456789abcdef0123456789abcdef", Name: "work computer"}, Attempt: "att_0123456789abcdef"}); err != nil { + t.Fatal(err) + } + _ = store.Close() + if err := first.Close(); err != nil { + t.Fatal(err) + } + second := open() + select { + case <-local.entered: + case <-time.After(5 * time.Second): + t.Fatal("the original local recovery did not start") + } + local.mu.Lock() + workspace, brief := local.spec.Workspace, local.spec.Brief + rootBrief := local.spec.Store.Task(local.spec.Store.RootID()).Description + local.mu.Unlock() + if workspace != tree.dir || brief != "" || rootBrief != "original local brief" { + t.Fatalf("local recovery changed copy or instruction: %q, %q, %q", workspace, brief, rootBrief) + } + if raw, err := os.ReadFile(sentinel); err != nil || string(raw) != "keep this" { + t.Fatalf("local recovery changed the retained copy: %q, %v", raw, err) + } + if words, err := second.Cancel(CancelTask + ":" + strconv.FormatUint(id, 10)); err != nil || !strings.HasPrefix(words, "stopping ") || !strings.Contains(words, "its branch is kept") { + t.Fatalf("local stop changed: %q, %v", words, err) + } + waitRemoteRun(t, second) + row, _ := runRowOf(second.graph(), id) + if row.State != TaskFailed || !row.Stopped || row.Ending != "" || row.EndedAt.IsZero() || !strings.HasPrefix(row.Report, "stopped") { + t.Fatalf("local stop changed its original ending: %+v", row) + } + if err := second.Close(); err != nil { + t.Fatal(err) + } + stopped, err := plandb.Open(g.planPath(), "", "", "", "") + if err != nil { + t.Fatal(err) + } + root := stopped.Task(stopped.RootID()) + _ = stopped.Close() + if root == nil || root.Status != plandb.StatusCancelled { + t.Fatalf("the stopped local plan can be replayed: %+v", root) + } + third := open() + third.beltMu.Lock() + live := third.beltRun + third.beltMu.Unlock() + if live != nil || computers.lists != 0 || computers.engines != 0 { + t.Fatalf("a local reopen used a task computer or replayed stopped work: live=%v lists=%d engines=%d", live != nil, computers.lists, computers.engines) + } +} + +// S1 and S2 require the person's choice. An argument absent from the default +// schema remains an ignored unknown field, as it was before paired tasks. +func TestTheModelCannotOptIntoAnotherComputerWithTheDefaultTool(t *testing.T) { + computers := &countedTaskComputers{} + a, local := remoteTaskAgent(t, nil, func(c *Config) { c.TaskComputers = computers }) + if result, refused := approveRemoteProposal(t, a, taskOnOtherComputer()); refused { + t.Fatalf("an argument outside the default schema changed local admission: %s", result) + } + <-local.entered + close(local.release) + waitRemoteRun(t, a) + if computers.lists != 0 || computers.engines != 0 { + t.Fatalf("the model opted in without the person: directory=%d, engine=%d", computers.lists, computers.engines) + } +} + +// S2 keeps the model's tool and belt facts unchanged until the person chooses +// a standing executor; pairing alone is not that choice. +func TestProposeTaskIsUnchangedUntilRunTasksOnNamesAComputer(t *testing.T) { + a, _ := newTestAgent(t, &scriptedCompleter{}, func(c *Config) { + c.ProfileDir = t.TempDir() + }) + original := a.taskTools()[0].Schema + facts := promptWithBeltFacts(a.config) + a.config.TaskComputers = testTaskComputers{} + if got := a.taskTools()[0].Schema; !bytes.Equal(got, original) { + t.Fatalf("pairing changed the default proposal schema:\n%s", got) + } + if got := promptWithBeltFacts(a.config); got != facts { + t.Fatal("pairing changed the default belt facts") + } + if err := config.WriteTaskRunsOn(a.config.ProfileDir, "dev_0123456789abcdef0123456789abcdef"); err != nil { + t.Fatal(err) + } + if _, offered := schemaProperties(t, a.taskTools()[0].Schema)["computer"]; !offered { + t.Fatal("the chosen standing executor has no computer argument") + } + if promptWithBeltFacts(a.config) == facts { + t.Fatal("the chosen standing executor has no computer guidance") + } + if err := config.WriteTaskRunsOn(a.config.ProfileDir, ""); err != nil { + t.Fatal(err) + } + if !bytes.Equal(a.taskTools()[0].Schema, original) || promptWithBeltFacts(a.config) != facts { + t.Fatal("resetting to this computer did not restore the original tool and facts") + } +} + +// S4 preserves the instruction a local proposal gives its root, even with checks. +func TestALocalTaskBriefHasNoChecksSection(t *testing.T) { + spec := taskSpec{brief: "Repair export", deliverable: "working export", acceptance: "export succeeds", checks: []string{"go test ./export"}} + const original = "THE WORK\n\nRepair export\n\nWHAT TO PRODUCE\n\nworking export\n\nDONE WHEN\n\nexport succeeds" + if got := proposalBrief(spec, nil); got != original { + t.Fatalf("local brief changed:\n%q", got) + } +} + +// S4 leaves every new placement field out of local checkpoints and wire notices. +func TestALocalTaskRecordOmitsEveryRemoteField(t *testing.T) { + for _, value := range []any{TaskNotice{ID: 7, Title: "Repair export"}, runRowRecord(TaskNotice{ID: 7, Title: "Repair export"}), TaskIndexEntry{ID: "7"}, UsageLine{}, journalUsage{}} { + raw, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + for _, field := range []string{"Computer", "Unreachable", "RanElsewhere", "Remote", "computer", "unreachable", "ran_elsewhere", "remote", "remote_event"} { + if strings.Contains(string(raw), `"`+field+`":`) { + t.Fatalf("%T wrote an empty placement field: %s", value, raw) + } + } + } +} + +// S4 keeps local ending words on their existing mapping, even when an engine +// reports a word the remote door also uses. +func TestALocalTaskKeepsItsOriginalEndingMapping(t *testing.T) { + a, _ := newTestAgent(t, &scriptedCompleter{}, nil) + notice := a.beltRunNotice(&beltRun{row: 7, title: "Repair export"}, RunSummary{Outcome: "interrupted"}, RunLanding{}) + if notice.State != TaskFailed { + t.Fatalf("local ending took a remote branch: %s", notice.State) + } +} + +// S2 applies to the next turn after changing the setting, including resetting +// it before the ordinary prompt clock is due to refresh. +func TestProposeTaskFactsFollowTheSettingOnTheNextTurn(t *testing.T) { + a, _ := newTestAgent(t, &scriptedCompleter{}, func(c *Config) { + c.System = "" + c.ProfileDir = t.TempDir() + c.TaskComputers = testTaskComputers{} + }) + now := time.Now().Truncate(time.Minute) + a.mu.Lock() + a.rerenderSystemLocked(now) + original := a.system + a.mu.Unlock() + if err := config.WriteTaskRunsOn(a.config.ProfileDir, "dev_0123456789abcdef0123456789abcdef"); err != nil { + t.Fatal(err) + } + a.mu.Lock() + a.refreshClockLocked(now.Add(time.Second)) + chosen := a.system + a.mu.Unlock() + if !strings.Contains(chosen, "optional `computer` argument") { + t.Fatal("the next turn lacks the chosen computer's belt fact") + } + if err := config.WriteTaskRunsOn(a.config.ProfileDir, ""); err != nil { + t.Fatal(err) + } + a.mu.Lock() + a.refreshClockLocked(now.Add(2 * time.Second)) + restored := a.system + a.mu.Unlock() + if restored != original { + t.Fatal("resetting the preference left a remote promise in the next turn") + } +} diff --git a/internal/tui3/app.go b/internal/tui3/app.go index 1f3d4547b6..8a317bf736 100644 --- a/internal/tui3/app.go +++ b/internal/tui3/app.go @@ -3630,7 +3630,7 @@ func (a *app) Init() tea.Cmd { // raised, and on the settings page — and reading it on the way up means // neither of those ever pays a round trip. The fold re-stamps whatever // questions the attach already replayed. - a.readAutonomy(), a.readTaskComputers(), + a.readAutonomy(), a.readPreferredTaskComputers(), // AND THE PULSE'S OWN BEAT, whose first reading is taken now rather than // ten seconds from now (pulsebeat.go). pulseNow, diff --git a/internal/tui3/input.go b/internal/tui3/input.go index a2f217ea7e..41f2bd8e97 100644 --- a/internal/tui3/input.go +++ b/internal/tui3/input.go @@ -1040,7 +1040,8 @@ func (a *app) key(msg tea.KeyPressMsg) tea.Cmd { case "ctrl+,": // The settings key every application on this machine already has. The // slash is the other door onto the same panel (settings.go). - return a.openSettings() + a.openSettings() + return nil case effortKey: // WALK THE THINKING LADDER (effortchip.go). It is bound here, in the plain diff --git a/internal/tui3/place_settings.go b/internal/tui3/place_settings.go index d820df95c8..6ad2a187e6 100644 --- a/internal/tui3/place_settings.go +++ b/internal/tui3/place_settings.go @@ -42,8 +42,8 @@ func (placeSettings) open(a *app) tea.Cmd { // bar counting while it was up ([placeSettings.tick]). // // OVER --host THE TEAMS TAB READS THE FAR MACHINE, once, off the loop. A - // local launch reads its paired task computers through the same off-loop road. - return tea.Batch(a.armPlaceClock(), a.readHostTeamDefaults(), a.ensureSheetDevices(), a.readTaskComputers()) + // local launch asks for nothing. + return tea.Batch(a.armPlaceClock(), a.readHostTeamDefaults(), a.ensureSheetDevices()) } // tick re-reads nothing and keeps the beat: see the note over [placeSettings.open]. diff --git a/internal/tui3/settings.go b/internal/tui3/settings.go index 02fd46ccc5..4a87104a7b 100644 --- a/internal/tui3/settings.go +++ b/internal/tui3/settings.go @@ -1289,7 +1289,7 @@ func (a *app) slotRefusal() error { // openSettings is /settings and ctrl+,, and it is THE ROUTER'S DOOR like every // other way into a place (pages.go's [app.showPage]). -func (a *app) openSettings() tea.Cmd { return a.showPage(pageSettings) } +func (a *app) openSettings() { a.showPage(pageSettings) } // raiseSettings builds the panel. It is [placeSettings]'s `open` and nothing // else calls it, which is what makes the router the one road in. @@ -2646,6 +2646,9 @@ func (a *app) activate() tea.Cmd { } } s.choice = choice + if item.row.Key == config.KeyTaskRunsOn { + return a.readTaskComputers() + } case widgetLane: // ENTER ON THIS ROW OPENS THE MACHINES. It used to walk four words — diff --git a/internal/tui3/settings_choice.go b/internal/tui3/settings_choice.go index 194ff16088..92bd6145ea 100644 --- a/internal/tui3/settings_choice.go +++ b/internal/tui3/settings_choice.go @@ -47,6 +47,10 @@ func (a *app) saveSheetChoice() tea.Cmd { } if a.applySetting(c.item, c.item.row.Choices[c.cursor]) { a.sheet.choice = nil + if c.item.row.Key == config.KeyTaskRunsOn { + a.touch() + return a.readTaskComputers() + } } a.touch() return nil diff --git a/internal/tui3/task.go b/internal/tui3/task.go index aa3f20c388..8dd229369c 100644 --- a/internal/tui3/task.go +++ b/internal/tui3/task.go @@ -911,7 +911,9 @@ func (a *app) taskEvent(ev session.Event) tea.Cmd { a.note(ev.Text) case session.EventTaskProposal: a.proposeTask(ev) - pilot = a.readTaskComputers() + if a.proposalComputerName(a.task) != "" { + pilot = a.readTaskComputers() + } case session.EventTaskUpdate: pilot = a.taskUpdate(ev) mentions = a.refreshTasks() diff --git a/internal/tui3/task_stability_test.go b/internal/tui3/task_stability_test.go new file mode 100644 index 0000000000..594ad2ea83 --- /dev/null +++ b/internal/tui3/task_stability_test.go @@ -0,0 +1,69 @@ +package tui3 + +import ( + "context" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/session" +) + +type countedSurfaceComputers struct { + surfaceTaskComputers + lists int +} + +func (c *countedSurfaceComputers) List(context.Context) ([]session.TaskComputer, error) { + c.lists++ + return c.surfaceTaskComputers, nil +} + +// S1 forbids a task-computer directory call for an ordinary proposal. +func TestAPairedHomeWithDefaultsNeverListsTaskComputers(t *testing.T) { + a, _, _ := taskApp(t) + c := &countedSurfaceComputers{surfaceTaskComputers: surfaceTaskComputers{{Device: surfaceComputerB, Name: "work computer", CanTakeTasks: true}}} + a.taskComputers = c + _ = runCmd(a.Init()) + if c.lists != 0 { + t.Fatalf("opening a paired chat listed task computers %d times", c.lists) + } + drive(t, a, taskEventMsg{gen: a.taskGen, ev: proposal(a, 7, 0)}) + if c.lists != 0 { + t.Fatalf("an ordinary proposal listed task computers %d times", c.lists) + } +} + +// S3 keeps both the original card and its key bindings until explicit placement. +func TestTheProposalCardSaysNothingAboutComputersByDefault(t *testing.T) { + a, _, _ := taskApp(t) + a.proposeTask(proposal(a, 7, 0)) + original := strings.Join(a.taskCardRows(a.task, 120, false), "\n") + a.taskComputers = surfaceTaskComputers{{Device: surfaceComputerB, Name: "work computer", CanTakeTasks: true}} + drive(t, a, taskMsg(a.readTaskComputers())) + if got := strings.Join(a.taskCardRows(a.task, 120, false), "\n"); got != original { + t.Fatalf("pairing changed the default proposal:\n%s", plain(got)) + } + if cmd, took := a.taskKey(key("ctrl+d")); cmd != nil || took { + t.Fatal("the default proposal claimed a computer key") + } +} + +// S10 makes the setting the default opt-in door, with no roster fetch on entry. +func TestTheSettingsRowIsTheOnlyNewThingAPairedPersonSeesByDefault(t *testing.T) { + a, _ := sheetApp(t) + c := &countedSurfaceComputers{surfaceTaskComputers: surfaceTaskComputers{{Device: surfaceComputerB, Name: "work computer", CanTakeTasks: true}}} + a.taskComputers = c + drive(t, a, key("ctrl+,")) + cursorTo(t, a, config.KeyTaskRunsOn) + if c.lists != 0 { + t.Fatalf("opening settings listed task computers %d times", c.lists) + } + if text := plain(frame(a)); !strings.Contains(text, "Run tasks on") || !strings.Contains(text, "this computer") { + t.Fatalf("missing default opt-in row:\n%s", text) + } + drive(t, a, key("enter")) + if c.lists != 1 || !strings.Contains(plain(frame(a)), "work computer") { + t.Fatalf("opening the row did not fetch its computers: calls=%d\n%s", c.lists, plain(frame(a))) + } +} diff --git a/internal/tui3/taskcomputer.go b/internal/tui3/taskcomputer.go index 3f5860b0d7..8f9b865650 100644 --- a/internal/tui3/taskcomputer.go +++ b/internal/tui3/taskcomputer.go @@ -86,8 +86,8 @@ func (a *app) runTaskHere(entry session.TaskIndexEntry) tea.Cmd { }) } -// The roster is read on entry, never while drawing. A failed refresh retains -// the last names rather than claiming a paired computer was removed. +// The roster is read for a placement choice, never while drawing. A failed +// refresh retains the last names rather than claiming a computer was removed. func (a *app) readTaskComputers() tea.Cmd { if !a.taskComputersAvailable() { return nil @@ -110,6 +110,20 @@ func (a *app) readTaskComputers() tea.Cmd { if a.sheet.registry != nil { a.sheet.taskComputerRows = a.taskComputerRows a.sheet.build() + if choice := a.sheet.choice; choice != nil && choice.item.row.Key == config.KeyTaskRunsOn { + choice.item.row = a.sheet.taskComputerSetting(choice.item.row) + choice.current = config.TaskRunsOnAt(a.sheet.profileDir) + choice.cursor = 0 + if taskComputerName(a.sheet.taskComputerRows, choice.current) == config.TaskRunsHere { + choice.current = "" + } + for i, value := range choice.item.row.Choices { + if value == choice.current { + choice.cursor = i + break + } + } + } } if a.task != nil { a.markCardStale(a.task) @@ -121,7 +135,7 @@ func (a *app) readTaskComputers() tea.Cmd { } func (a *app) proposalComputerName(card *taskCard) string { - if !a.taskComputersAvailable() { + if card == nil || !a.taskComputersAvailable() || (card.computer == "" && card.computerPreference == "") { return "" } named := strings.TrimSpace(card.computer) @@ -140,7 +154,7 @@ func (a *app) proposalComputerName(card *taskCard) string { } func (a *app) canCycleTaskComputer(card *taskCard) bool { - return card != nil && !card.forming && !card.settled() && a.taskComputersAvailable() && len(a.taskComputerRows) > 0 + return card != nil && !card.forming && !card.settled() && a.proposalComputerName(card) != "" && len(a.taskComputerRows) > 0 } // A pick belongs to this proposal, not to the standing preference. The answer @@ -252,3 +266,12 @@ func (a *app) taskEntryComputerWords(entry session.TaskIndexEntry) string { } return taskComputerWords(entry.Computer, entry.Unreachable, entry.RanElsewhere, session.TaskState(entry.Status)) } + +// A saved remote default is itself the person's choice to consult the roster. +// An unchanged paired home must make no task-computer call at startup. +func (a *app) readPreferredTaskComputers() tea.Cmd { + if config.TaskRunsOnAt(a.profileDir) == "" { + return nil + } + return a.readTaskComputers() +} diff --git a/internal/tui3/taskcomputer_test.go b/internal/tui3/taskcomputer_test.go index c14c26fd8c..ac8c38b8eb 100644 --- a/internal/tui3/taskcomputer_test.go +++ b/internal/tui3/taskcomputer_test.go @@ -42,7 +42,7 @@ func (*olderTaskComputerSurface) TaskComputersSupported() bool { return false } // The wire compatibility law requires the welcome's missing capability to // hide the new controls even though the client now has their Go methods. func TestAnOlderEngineHasNoTaskComputerFlagOrRecoveryVerb(t *testing.T) { - a, f, _ := computerRecoveryApp(t, session.TaskInterrupted, &session.TaskRemoteRecord{StartFailed: true}, "work computer is not taking tasks right now — run codeaf serve on it") + a, f, _ := computerRecoveryApp(t, session.TaskInterrupted, &session.TaskRemoteRecord{StartFailed: true}, "work computer is not taking tasks right now — run codeaf serve --take-tasks on it") a.agent = &olderTaskComputerSurface{f} if strings.Contains(a.taskCommandUsage(), "--on") { t.Fatal("an older engine advertises --on") @@ -240,7 +240,8 @@ func TestTaskOnWithAnUnknownNameListsTheComputers(t *testing.T) { // placement disclosure before approval, independent of the later run state. func TestTheProposalCardNamesItsExplicitOrDefaultComputer(t *testing.T) { for _, tc := range []struct{ computer, preference, shown string }{ - {"", "", "this computer"}, + {"", "", ""}, + {"Abir's Mac", "", "Abir's Mac"}, {"", surfaceComputerB, "work computer"}, {"here", surfaceComputerB, "this computer"}, {"Abir's Mac", surfaceComputerB, "Abir's Mac"}, @@ -255,7 +256,12 @@ func TestTheProposalCardNamesItsExplicitOrDefaultComputer(t *testing.T) { ev := proposal(a, 7, 0) ev.Task.Computer = tc.computer a.proposeTask(ev) - if text := plain(strings.Join(a.taskCardRows(a.task, 120, false), "\n")); !strings.Contains(text, "runs on "+tc.shown) { + text := plain(strings.Join(a.taskCardRows(a.task, 120, false), "\n")) + if tc.shown == "" { + if strings.Contains(text, "runs on") || strings.Contains(text, "ctrl+d computer") { + t.Fatalf("a default proposal disclosed computers:\n%s", text) + } + } else if !strings.Contains(text, "runs on "+tc.shown) { t.Fatalf("the proposal does not say where it will run:\n%s", text) } }) @@ -364,7 +370,7 @@ func TestAComputerThatStoppedAnsweringOffersRunItHere(t *testing.T) { // TestAComputerNotTakingTasksSaysToRunServeOnIt maps to DESIGN contract 8: // the session's actual refusal is retained beside the local replacement verb. func TestAComputerNotTakingTasksSaysToRunServeOnIt(t *testing.T) { - const words = "work computer is not taking tasks right now — run codeaf serve on it" + const words = "work computer is not taking tasks right now — run codeaf serve --take-tasks on it" a, f, _ := computerRecoveryApp(t, session.TaskInterrupted, &session.TaskRemoteRecord{StartFailed: true}, words) rows, _, _, _ := a.taskCardFrame(120, 40) if text := plain(strings.Join(rows, "\n")); !strings.Contains(text, words) || strings.Count(text, "h run it here") != 1 { From 8b47c065e7251230022a74a9f4e8d347a12b95c3 Mon Sep 17 00:00:00 2001 From: Abir Abbas <abirabbas1998@gmail.com> Date: Fri, 30 Oct 2026 22:38:48 -0400 Subject: [PATCH 15/34] serve: take tasks only with --take-tasks, and leave pairing's code alone Plain codeaf serve registers only the machine door, as on dev. The task door opens with --take-tasks and keeps its own registration loop, so the pairing and machine-door code is dev's byte for byte; only the task tunnel records a channel binding. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- cmd/codeaf/chatv3_at.go | 8 +- internal/e2e/task_other_home_e2e_test.go | 36 ++++++- internal/pair/serve.go | 88 +++++++++++++++-- internal/pair/task_tunnel_test.go | 48 +++++++++ internal/pair/tunnel.go | 103 ++++++++++++++++++-- internal/taskdoor/door_test.go | 2 +- internal/taskdoor/hello.go | 8 +- internal/{pair => taskdoor}/registration.go | 30 +++--- internal/taskdoor/security_test.go | 2 +- internal/taskdoor/serve.go | 2 +- 10 files changed, 285 insertions(+), 42 deletions(-) rename internal/{pair => taskdoor}/registration.go (83%) diff --git a/cmd/codeaf/chatv3_at.go b/cmd/codeaf/chatv3_at.go index 1d6340d7b9..c5f5e459fd 100644 --- a/cmd/codeaf/chatv3_at.go +++ b/cmd/codeaf/chatv3_at.go @@ -48,6 +48,7 @@ import ( "github.com/Agent-Field/codeaf/internal/pair" "github.com/Agent-Field/codeaf/internal/remote" + "github.com/Agent-Field/codeaf/internal/taskdoor" codeupdate "github.com/Agent-Field/codeaf/internal/update" ) @@ -261,6 +262,7 @@ func askPairingCode(name string) (string, error) { // walks out to the relay and stays there. func runServe(args []string) error { flags := commandFlags("serve") + takeTasks := flags.Bool(taskdoor.TakeTasksFlag, false, "take tasks from your paired computers") workspace := flags.String("workspace", "", "directory a connection works in when it does not name one; empty is the directory this command was run in") relayAddress := flags.String("via", "", "the sync address to be reachable through; empty reads "+pair.RelayEnv+", ~/.codeaf/v3/remote/relay, then the address this machine syncs through") renamedFlag(flags, "relay", "via") @@ -268,7 +270,7 @@ func runServe(args []string) error { return err } if flags.NArg() != 0 { - return errors.New("usage: codeaf serve [--workspace path] [--via https://…]") + return errors.New("usage: codeaf serve [--workspace path] [--via https://…] [--" + taskdoor.TakeTasksFlag + "]") } service := strings.TrimSpace(*relayAddress) @@ -300,7 +302,9 @@ func runServe(args []string) error { ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() defer startServeKeeping(ctx, service)() - defer serveTasks(ctx, service)() + if *takeTasks { + defer serveTasks(ctx, service)() + } host := &pair.Host{ Service: service, Device: device, diff --git a/internal/e2e/task_other_home_e2e_test.go b/internal/e2e/task_other_home_e2e_test.go index 5f3892bc98..dc8e600985 100644 --- a/internal/e2e/task_other_home_e2e_test.go +++ b/internal/e2e/task_other_home_e2e_test.go @@ -30,6 +30,7 @@ import ( "github.com/Agent-Field/codeaf/internal/remote" "github.com/Agent-Field/codeaf/internal/session" "github.com/Agent-Field/codeaf/internal/syncsetup" + "github.com/Agent-Field/codeaf/internal/taskdoor" ) const otherHomeModel = "stub/paired-task" @@ -232,7 +233,7 @@ func TestATaskForAComputerNotServingSaysSoAndRunsHere(t *testing.T) { stop() a := f.openConversation(t, false) id := f.startTask(t, a) - want := "work computer is not taking tasks right now — run codeaf serve on it" + want := "work computer is not taking tasks right now — run codeaf serve --take-tasks on it" f.waitNotice(t, a, id, func(notice session.TaskNotice) bool { return notice.Remote != nil && notice.Remote.StartFailed && strings.Contains(notice.Report, want) }) @@ -249,6 +250,23 @@ func TestATaskForAComputerNotServingSaysSoAndRunsHere(t *testing.T) { } } +// S5: a real paired home running plain serve never opts into receiving tasks. +func TestPlainServeTakesNoTasks(t *testing.T) { + f := newOtherHomeTask(t) + f.startServe(t, false) + a := f.openConversation(t, false) + id := f.startTask(t, a) + f.waitNotice(t, a, id, func(n session.TaskNotice) bool { + return n.Remote != nil && n.Remote.StartFailed && strings.Contains(n.Report, taskdoor.NotTakingTasks("work computer")) + }) + if calls, _ := f.brainB.counts(); calls != 0 { + t.Fatalf("plain serve ran a task: provider calls=%d", calls) + } + if _, err := os.Stat(filepath.Join(f.b, "v3", "taskdoor")); !os.IsNotExist(err) { + t.Fatalf("plain serve opened task storage: %v", err) + } +} + type otherHomeTask struct { ctx context.Context a, b, project, relay, product string @@ -380,14 +398,24 @@ func (f *otherHomeTask) env(home string) []string { } func (f *otherHomeTask) startServing(t *testing.T) func() { + return f.startServe(t, true) +} + +func (f *otherHomeTask) startServe(t *testing.T, takeTasks bool) func() { t.Helper() log := filepath.Join(f.b, "serve.log") - cmd := guardedCommand(t, f.ctx, f.b, f.env(f.b), f.product, "serve", "--via", f.relay) + args := []string{"serve", "--via", f.relay} + ready := "this machine is reachable as" + if takeTasks { + args = append(args, "--take-tasks") + ready = taskdoor.TakingTasksLine + } + cmd := guardedCommand(t, f.ctx, f.b, f.env(f.b), f.product, args...) cmd.Dir = f.b stop := otherHomeProcess(t, cmd, log) - otherHomeWait(t, f.ctx, "codeaf serve's task door", func() bool { + otherHomeWait(t, f.ctx, "codeaf serve", func() bool { raw, _ := os.ReadFile(log) - return strings.Contains(string(raw), "takes tasks from your paired computers") + return strings.Contains(string(raw), ready) }) return stop } diff --git a/internal/pair/serve.go b/internal/pair/serve.go index cdc1b1b414..56fed9b108 100644 --- a/internal/pair/serve.go +++ b/internal/pair/serve.go @@ -145,14 +145,90 @@ func (h *Host) Run(ctx context.Context) error { if h.Desk != nil && h.Desk.Now == nil && h.Now != nil { h.Desk.Now = h.Now } - return (&RegistrationLoop{ - Service: h.Service, Key: h.Device.Private(), Say: h.Say, - Now: h.now, Wait: h.wait, Hold: h.hold, - Ready: func(registration *relay.Registration) { + wait := time.Second + announced := false + // told and throttled are each said once per spell, not once per retry. + // told is set when the person has been told sync was lost and not yet + // that it came back, so the two lines always arrive as a pair. + told := false + throttled := false + tellLost := func() { + if !told { + h.say(LostSyncLine) + } + told = true + } + for { + registration, err := relay.Register(ctx, h.Service, h.Device.Private()) + switch { + case err == nil: + case errors.Is(err, relay.ErrTooMany): + if !throttled { + h.say(TooManyRegistrationsLine) + } + throttled = true + case errors.Is(err, relay.ErrNameTaken) && announced: + // This machine held the name a moment ago, so what holds it now is + // its own dead connection, which the relay forgets when it times + // out. That is the outage still going on, and is waited out. + tellLost() + case errors.Is(err, relay.ErrNameTaken): + // RULE THREE OF THE NAME POLICY, MET FROM THIS SIDE. Something is + // already connected under this machine's name, which in practice + // means a second `codeaf serve` on this same machine. Trying again + // forever would be two processes fighting over one name. + return fmt.Errorf("this machine is already connected to sync as %s — there is only one of it, so close the other `codeaf serve`", h.Device.Name()) + case errors.Is(err, relay.ErrUnreachable): + if !announced { + return Unreachable(h.Service) + } + tellLost() + case errors.Is(err, relay.ErrNoDoor) && announced: + // The door was there a moment ago, so this is a relay being + // redeployed, and is waited out like any other outage. + tellLost() + case errors.Is(err, relay.ErrNoDoor): + // Trying again cannot add a door the service does not have. + return NoDoor(h.Service) + default: + if !announced { + return err + } + h.say("sync refused this machine: " + err.Error()) + } + + if err == nil { + announced = true + // A LOST LINE IS ANSWERED BY A BACK LINE. The banner is reprinted only + // when the name or code changed, so without this the last thing on + // the screen of a machine that recovered would still say it was lost. + if told { + h.say(BackLine(registration.Name())) + told = false + } code, codeErr := h.Desk.Offer() + // Reprints happen only when the name or pairing code actually + // changed, so a brief network blip neither spams the screen nor + // disguises a flapping carrier as a fresh start. h.sayBanner(h.banner(registration.Name(), code, codeErr), false) - }, - }).Run(ctx) + since := h.now() + h.hold(ctx, registration) + // Backoff resets only after the carrier stayed up for a meaningful + // stretch; an upgrade that drops right after Register keeps + // backing off instead of hammering the relay every second. + if h.now().Sub(since) >= steadyAfter { + wait = time.Second + throttled = false + } + } + + if err := h.wait(ctx, wait); err != nil { + return nil + } + if wait *= 2; wait > time.Minute { + wait = time.Minute + } + } } // hold answers arrivals until the registration ends, and returns only after diff --git a/internal/pair/task_tunnel_test.go b/internal/pair/task_tunnel_test.go index d87a09d582..681c98ccb2 100644 --- a/internal/pair/task_tunnel_test.go +++ b/internal/pair/task_tunnel_test.go @@ -10,6 +10,54 @@ import ( "time" ) +// S6 leaves the machine handshake on its original protocol without recording +// the channel binding that only the separately opted-in task door needs. +func TestPairingAndTheMachineDoorKeepTheirOriginalHandshake(t *testing.T) { + a, err := ecdh.X25519().GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + b, err := ecdh.X25519().GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + near, far := net.Pipe() + defer near.Close() + defer far.Close() + _ = near.SetDeadline(time.Now().Add(3 * time.Second)) + _ = far.SetDeadline(time.Now().Add(3 * time.Second)) + type answer struct { + tunnel *Tunnel + err error + } + result := make(chan answer, 1) + go func() { + var intent [1]byte + if _, err := io.ReadFull(far, intent[:]); err != nil { + result <- answer{err: err} + return + } + server, _, err := acceptAsMachine(far, Device{private: b}, func(key []byte, label string) error { + if !bytes.Equal(key, a.PublicKey().Bytes()) || label != "chat computer" { + return io.ErrUnexpectedEOF + } + return nil + }) + result <- answer{server, err} + }() + client, err := connectAsSurface(near, b.PublicKey().Bytes(), Device{private: a}, "chat computer") + if err != nil { + t.Fatal(err) + } + server := <-result + if server.err != nil { + t.Fatal(server.err) + } + if len(client.ChannelBinding()) != 0 || len(server.tunnel.ChannelBinding()) != 0 { + t.Fatal("the original machine handshake recorded a task channel binding") + } +} + // Contract 14 depends on an encrypted task channel whose binding both devices // can sign; the separate prologue prevents a machine door accepting a task. func TestTaskTunnelBindsBothEndsAndSeparatesMachineDoors(t *testing.T) { diff --git a/internal/pair/tunnel.go b/internal/pair/tunnel.go index 9708c19281..bc1cd5e59d 100644 --- a/internal/pair/tunnel.go +++ b/internal/pair/tunnel.go @@ -73,7 +73,7 @@ func ConnectWithKey(conn io.ReadWriteCloser, peer []byte, key *ecdh.PrivateKey) if key == nil { return nil, errors.New("pair: a task tunnel needs a static key") } - return connectWithStatic(conn, peer, Device{private: key}.noiseKey(), "", taskTunnelPrologue) + return connectTaskTunnel(conn, peer, Device{private: key}.noiseKey(), "", taskTunnelPrologue) } // AcceptWithKey answers an identity task tunnel. Device authorization follows @@ -89,7 +89,7 @@ func AcceptWithKey(conn io.ReadWriteCloser, key *ecdh.PrivateKey) (*Tunnel, erro if intent[0] != intentConnect { return nil, errors.New("this door takes task connections") } - tunnel, _, err := acceptWithStatic(conn, Device{private: key}.noiseKey(), func([]byte, string) error { return nil }, taskTunnelPrologue) + tunnel, _, err := acceptTaskTunnel(conn, Device{private: key}.noiseKey(), func([]byte, string) error { return nil }, taskTunnelPrologue) return tunnel, err } @@ -152,10 +152,99 @@ func (t *Tunnel) Close() error { // connectAsSurface dials a machine whose key this device already pinned. func connectAsSurface(conn io.ReadWriteCloser, machineKey []byte, me Device, label string) (*Tunnel, error) { - return connectWithStatic(conn, machineKey, me.noiseKey(), label, protocol+" connect") + if _, err := conn.Write([]byte{intentConnect}); err != nil { + return nil, err + } + handshake, err := noise.NewHandshakeState(noise.Config{ + CipherSuite: suite, + Pattern: noise.HandshakeIK, + Initiator: true, + Prologue: []byte(protocol + " connect"), + StaticKeypair: me.noiseKey(), + PeerStatic: machineKey, + }) + if err != nil { + return nil, err + } + // THE PAYLOAD IS WHAT PROVES THIS DEVICE HOLDS ITS KEY. In IK the first + // message carries this device's static key and a static-static exchange, + // and the tag over this payload is under a key that exchange produced — so + // a machine that opens it has proof, and a device replaying somebody else's + // captured static key produces a message that does not open. + message, _, _, err := handshake.WriteMessage(nil, []byte(label)) + if err != nil { + return nil, err + } + if err := writeRecord(conn, message); err != nil { + return nil, err + } + reply, err := readRecord(conn) + if err != nil { + // NOTHING CAME BACK, which is what a machine that could not read the + // first message looks like from here: IK encrypts this device's own + // static key to the key it pinned, so a machine holding a different key + // cannot open the message and has nothing to answer with. + return nil, ErrNoAnswer + } + said, first, second, err := handshake.ReadMessage(nil, reply) + if err != nil { + return nil, ErrNotThatMachine + } + if first == nil || second == nil { + return nil, errors.New("that machine answered with a handshake this build cannot finish") + } + if err := hearVerdict(said); err != nil { + return nil, err + } + return &Tunnel{conn: conn, peer: machineKey, send: first, recv: second}, nil } -func connectWithStatic(conn io.ReadWriteCloser, machineKey []byte, static noise.DHKey, label, prologue string) (*Tunnel, error) { +// acceptAsMachine answers a connection, and asks `allow` whether the device on +// the other end may open a conversation here. +// +// THE VERDICT TRAVELS INSIDE THE ENCRYPTION. A device that has been stopped +// gets a sentence saying so, and it gets it from the machine itself — the relay +// neither writes it nor reads it. +func acceptAsMachine(conn io.ReadWriteCloser, me Device, allow func(key []byte, label string) error) (*Tunnel, []byte, error) { + handshake, err := noise.NewHandshakeState(noise.Config{ + CipherSuite: suite, + Pattern: noise.HandshakeIK, + Initiator: false, + Prologue: []byte(protocol + " connect"), + StaticKeypair: me.noiseKey(), + }) + if err != nil { + return nil, nil, err + } + message, err := readRecord(conn) + if err != nil { + return nil, nil, err + } + said, _, _, err := handshake.ReadMessage(nil, message) + if err != nil { + return nil, nil, errors.New("a connection arrived that this machine could not make sense of") + } + deviceKey := append([]byte{}, handshake.PeerStatic()...) + verdict := "" + if err := allow(deviceKey, readableLabel(string(said))); err != nil { + verdict = err.Error() + } + reply, first, second, err := handshake.WriteMessage(nil, sayVerdict(verdict)) + if err != nil { + return nil, deviceKey, err + } + if err := writeRecord(conn, reply); err != nil { + return nil, deviceKey, err + } + if verdict != "" { + return nil, deviceKey, errors.New(verdict) + } + // The responder writes with the second cipher state and reads with the + // first, which is Noise's own ordering: the first is the initiator's. + return &Tunnel{conn: conn, peer: deviceKey, send: second, recv: first}, deviceKey, nil +} + +func connectTaskTunnel(conn io.ReadWriteCloser, machineKey []byte, static noise.DHKey, label, prologue string) (*Tunnel, error) { if _, err := conn.Write([]byte{intentConnect}); err != nil { return nil, err } @@ -209,11 +298,7 @@ func connectWithStatic(conn io.ReadWriteCloser, machineKey []byte, static noise. // THE VERDICT TRAVELS INSIDE THE ENCRYPTION. A device that has been stopped // gets a sentence saying so, and it gets it from the machine itself — the relay // neither writes it nor reads it. -func acceptAsMachine(conn io.ReadWriteCloser, me Device, allow func(key []byte, label string) error) (*Tunnel, []byte, error) { - return acceptWithStatic(conn, me.noiseKey(), allow, protocol+" connect") -} - -func acceptWithStatic(conn io.ReadWriteCloser, static noise.DHKey, allow func(key []byte, label string) error, prologue string) (*Tunnel, []byte, error) { +func acceptTaskTunnel(conn io.ReadWriteCloser, static noise.DHKey, allow func(key []byte, label string) error, prologue string) (*Tunnel, []byte, error) { handshake, err := noise.NewHandshakeState(noise.Config{ CipherSuite: suite, Pattern: noise.HandshakeIK, diff --git a/internal/taskdoor/door_test.go b/internal/taskdoor/door_test.go index aa977c1d4f..08a52bb740 100644 --- a/internal/taskdoor/door_test.go +++ b/internal/taskdoor/door_test.go @@ -263,7 +263,7 @@ func TestADoorThatIsNotServingSaysSo(t *testing.T) { if c != nil { _ = c.Close() } - if err == nil || err.Error() != "work computer is not taking tasks right now — run codeaf serve on it" { + if err == nil || err.Error() != "work computer is not taking tasks right now — run codeaf serve --take-tasks on it" { t.Fatalf("absent door heard %v", err) } if time.Since(since) > 10*time.Second { diff --git a/internal/taskdoor/hello.go b/internal/taskdoor/hello.go index 812acc4d83..b498bbda5d 100644 --- a/internal/taskdoor/hello.go +++ b/internal/taskdoor/hello.go @@ -15,12 +15,14 @@ import ( ) const ( - RemovedComputerLine = "this computer was removed from your devices" - TakingTasksLine = "takes tasks from your paired computers" + RemovedComputerLine = "this computer was removed from your devices" + TakingTasksLine = "takes tasks from your paired computers" + TakeTasksFlag = "take-tasks" + notTakingTasksSuffix = " is not taking tasks right now — run codeaf serve --" + TakeTasksFlag + " on it" ) func NotTakingTasks(name string) string { - return name + " is not taking tasks right now — run codeaf serve on it" + return name + notTakingTasksSuffix } func CouldNotConfirm(name string) string { return "could not confirm this is " + name } func DifferentVersion(name string) string { diff --git a/internal/pair/registration.go b/internal/taskdoor/registration.go similarity index 83% rename from internal/pair/registration.go rename to internal/taskdoor/registration.go index d5f8caad49..9bafa59483 100644 --- a/internal/pair/registration.go +++ b/internal/taskdoor/registration.go @@ -1,4 +1,4 @@ -package pair +package taskdoor import ( "context" @@ -7,13 +7,13 @@ import ( "fmt" "time" + "github.com/Agent-Field/codeaf/internal/pair" "github.com/Agent-Field/codeaf/internal/relay" ) -// RegistrationLoop keeps a key-derived relay door registered. Both machine -// and task doors share outage handling so a reconnect never changes what -// codeaf serve tells a person about sync. -type RegistrationLoop struct { +// The task door deliberately duplicates pairing's registration loop: stability +// of pairing outranks sharing the loop. +type registrationLoop struct { Service string Key *ecdh.PrivateKey Say func(string) @@ -23,18 +23,18 @@ type RegistrationLoop struct { Hold func(context.Context, *relay.Registration) } -func (r *RegistrationLoop) say(line string) { +func (r *registrationLoop) say(line string) { if r.Say != nil { r.Say(line) } } -func (r *RegistrationLoop) now() time.Time { +func (r *registrationLoop) now() time.Time { if r.Now != nil { return r.Now() } return time.Now() } -func (r *RegistrationLoop) wait(ctx context.Context, d time.Duration) error { +func (r *registrationLoop) wait(ctx context.Context, d time.Duration) error { if r.Wait != nil { return r.Wait(ctx, d) } @@ -50,7 +50,7 @@ func (r *RegistrationLoop) wait(ctx context.Context, d time.Duration) error { // Run returns only on cancellation or a refusal that retrying cannot fix. // Hold owns accepted streams until the registration ends. -func (r *RegistrationLoop) Run(ctx context.Context) error { +func (r *registrationLoop) Run(ctx context.Context) error { if r.Key == nil || r.Hold == nil { return errors.New("pair: a registration needs a key and a stream handler") } @@ -63,7 +63,7 @@ func (r *RegistrationLoop) Run(ctx context.Context) error { throttled := false tellLost := func() { if !told { - r.say(LostSyncLine) + r.say(pair.LostSyncLine) } told = true } @@ -73,7 +73,7 @@ func (r *RegistrationLoop) Run(ctx context.Context) error { case err == nil: case errors.Is(err, relay.ErrTooMany): if !throttled { - r.say(TooManyRegistrationsLine) + r.say(pair.TooManyRegistrationsLine) } throttled = true case errors.Is(err, relay.ErrNameTaken) && announced: @@ -89,7 +89,7 @@ func (r *RegistrationLoop) Run(ctx context.Context) error { return fmt.Errorf("this machine is already connected to sync as %s — there is only one of it, so close the other `codeaf serve`", relay.NameFor(r.Key.PublicKey().Bytes())) case errors.Is(err, relay.ErrUnreachable): if !announced { - return Unreachable(r.Service) + return pair.Unreachable(r.Service) } tellLost() case errors.Is(err, relay.ErrNoDoor) && announced: @@ -98,7 +98,7 @@ func (r *RegistrationLoop) Run(ctx context.Context) error { tellLost() case errors.Is(err, relay.ErrNoDoor): // Trying again cannot add a door the service does not have. - return NoDoor(r.Service) + return pair.NoDoor(r.Service) default: if !announced { return err @@ -112,7 +112,7 @@ func (r *RegistrationLoop) Run(ctx context.Context) error { // when the name or code changed, so without this the last thing on // the screen of a machine that recovered would still say it was lost. if told { - r.say(BackLine(registration.Name())) + r.say(pair.BackLine(registration.Name())) told = false } if r.Ready != nil { @@ -123,7 +123,7 @@ func (r *RegistrationLoop) Run(ctx context.Context) error { // Backoff resets only after the carrier stayed up for a meaningful // stretch; an upgrade that drops right after Register keeps // backing off instead of hammering the relay every second. - if r.now().Sub(since) >= steadyAfter { + if r.now().Sub(since) >= time.Minute { wait = time.Second throttled = false } diff --git a/internal/taskdoor/security_test.go b/internal/taskdoor/security_test.go index 3a1c75f4c7..040858b2b1 100644 --- a/internal/taskdoor/security_test.go +++ b/internal/taskdoor/security_test.go @@ -408,7 +408,7 @@ func TestARegisteredDoorThatDoesNotAnswerHasTheServingSentence(t *testing.T) { if conn != nil { _ = conn.Close() } - if err == nil || err.Error() != "work computer is not taking tasks right now — run codeaf serve on it" { + if err == nil || err.Error() != "work computer is not taking tasks right now — run codeaf serve --take-tasks on it" { t.Fatalf("silent door heard %v", err) } // Scheduling adds a small observation margin to the ten-second I/O bound. diff --git a/internal/taskdoor/serve.go b/internal/taskdoor/serve.go index 4a23b7ffef..2d104bb31f 100644 --- a/internal/taskdoor/serve.go +++ b/internal/taskdoor/serve.go @@ -84,7 +84,7 @@ func Serve(ctx context.Context, config Config) error { server.execute(runCtx, a, true) } announced := false - return (&pair.RegistrationLoop{ + return (®istrationLoop{ Service: config.Service, Key: key, Say: config.Say, Now: clock.Now, Wait: func(ctx context.Context, d time.Duration) error { ticker := clock.NewTicker(d) From 90d075a67cd535b89a0c91ebd93ee552d63240b3 Mon Sep 17 00:00:00 2001 From: Abir Abbas <abirabbas1998@gmail.com> Date: Fri, 30 Oct 2026 22:38:48 -0400 Subject: [PATCH 16/34] run: order steps across tasks only inside the task door's child An ordinary run's trajectory lines and codeaf do are dev's; the lock, the scan and the order fields are switched on only by the hidden task store flag the door passes its child. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- cmd/codeaf/do.go | 1 + internal/run/task_controls_stability_test.go | 38 +++++++++++++++ internal/run/task_stability_test.go | 50 ++++++++++++++++++++ internal/run/trajectory.go | 34 ++++++++++++- 4 files changed, 122 insertions(+), 1 deletion(-) create mode 100644 internal/run/task_controls_stability_test.go create mode 100644 internal/run/task_stability_test.go diff --git a/cmd/codeaf/do.go b/cmd/codeaf/do.go index 032d5e1e1c..32e58b5a8d 100644 --- a/cmd/codeaf/do.go +++ b/cmd/codeaf/do.go @@ -3651,6 +3651,7 @@ func parseSlots(raw string) (*int, error) { // the resident road keeps. func runErrand(request doRequest, seats config.Seats) (outcome headlessOutcome, runErr error) { if request.runStore != "" { + defer runengine.RecordTaskTrajectoryOrder()() // The task door reads per-call ledger rows after this process exits; // queued final charges must reach disk before its receipt is printed. defer session.CloseUsage() diff --git a/internal/run/task_controls_stability_test.go b/internal/run/task_controls_stability_test.go new file mode 100644 index 0000000000..1ff76fb576 --- /dev/null +++ b/internal/run/task_controls_stability_test.go @@ -0,0 +1,38 @@ +package run_test + +import ( + "context" + "testing" + + "github.com/Agent-Field/agentfield/sdk/go/ai" + "github.com/Agent-Field/codeaf/internal/effort" + "github.com/Agent-Field/codeaf/internal/run" +) + +// S8 preserves the worker's six-action bound unless its caller explicitly +// supplies task controls. Both cases use the real worker and its trajectory. +func TestBashWorkerWithoutTaskControlsKeepsItsDefaultLimit(t *testing.T) { + for _, chosen := range []bool{false, true} { + name, want := "ordinary", 6 + if chosen { + name, want = "task controls", 2 + } + t.Run(name, func(t *testing.T) { + t.Setenv("CODEAF_TASK_BELT", "bash") + t.Setenv("CODEAF_PLANDB_BIN", stubCLI(t)) + store := runOpenStore(t) + seat := &seat{ever: func(context.Context, []ai.Message) (*ai.Response, error) { + return toolReply(`{"command":"printf 'same answer'"}`), nil + }} + var options []run.WorkerOption + if chosen { + options = append(options, run.WithTaskControls(effort.None, want)) + } + worker := run.NewBashWorker(store, t.TempDir(), "test/model", seat, options...) + report, err := worker.Run(run.WithStepsPerTask(runContext(t), 60), *store.Task(store.RootID())) + if err == nil || report.Steps != want { + t.Fatalf("worker ended after %d actions, want %d: %v", report.Steps, want, err) + } + }) + } +} diff --git a/internal/run/task_stability_test.go b/internal/run/task_stability_test.go new file mode 100644 index 0000000000..6dc5db5872 --- /dev/null +++ b/internal/run/task_stability_test.go @@ -0,0 +1,50 @@ +package run + +import ( + "os" + "path/filepath" + "testing" + + "github.com/Agent-Field/codeaf/internal/plandb" +) + +// S4 and S7 preserve the original append bytes for an ordinary run. +func TestALocalStepLineHasNoOrderFields(t *testing.T) { + dir := t.TempDir() + // A sibling path that cannot be read as a file must never be scanned by + // an ordinary append. The original append only touches its own record. + if err := os.MkdirAll(filepath.Join(plandb.TaskDir(dir, "sibling"), trajectoryName), 0700); err != nil { + t.Fatal(err) + } + if err := appendTrajectory(dir, "1", Step{Kind: "step", Step: 1, Command: "pwd"}); err != nil { + t.Fatal(err) + } + raw, err := os.ReadFile(filepath.Join(plandb.TaskDir(dir, "1"), trajectoryName)) + if err != nil { + t.Fatal(err) + } + const original = "{\"kind\":\"step\",\"step\":1,\"command\":\"pwd\"}\n" + if string(raw) != original { + t.Fatalf("local trajectory bytes changed: %s", raw) + } +} + +// S7 orders only the opted-in child, across tasks, and restores local bytes. +func TestOnlyTheTaskDoorChildRecordsStepOrder(t *testing.T) { + dir := t.TempDir() + restore := RecordTaskTrajectoryOrder() + for _, id := range []string{"2", "1"} { + if err := appendTrajectory(dir, id, Step{Kind: "step", Step: 1, Command: "pwd"}); err != nil { + restore() + t.Fatal(err) + } + } + restore() + for i, id := range []string{"2", "1"} { + steps, err := Trajectory(dir, id) + if err != nil || len(steps) != 1 || steps[0].RecordedOrder != uint64(i+1) || steps[0].RecordedAt.IsZero() { + t.Fatalf("task %s order=%+v, %v", id, steps, err) + } + } + TestALocalStepLineHasNoOrderFields(t) +} diff --git a/internal/run/trajectory.go b/internal/run/trajectory.go index 698b61acd9..c90b79ac22 100644 --- a/internal/run/trajectory.go +++ b/internal/run/trajectory.go @@ -17,6 +17,7 @@ import ( "path/filepath" "strings" "sync" + "sync/atomic" "time" "unicode/utf8" @@ -190,7 +191,7 @@ func Trajectory(storeDir, id string) ([]Step, error) { return steps, nil } -// One writer orders all trajectories of a run. The follower can wait for a +// Inside the task child, one writer orders all trajectories of its run. The follower can wait for a // missing order when a directory scan observes a later task's append first. var trajectoryWriter = struct { sync.Mutex @@ -202,6 +203,28 @@ var trajectoryWriter = struct { // write: the record grows in the order the worker took its steps, and a // reader walking the file sees the run the way it happened. func appendTrajectory(storeDir, id string, step Step) error { + if taskTrajectoryOrdering.Load() { + return appendTaskTrajectory(storeDir, id, step) + } + dir := plandb.TaskDir(storeDir, id) + if err := os.MkdirAll(dir, 0o700); err != nil { + return err + } + line, err := json.Marshal(step) + if err != nil { + return err + } + file := filepath.Join(dir, trajectoryName) + f, err := os.OpenFile(file, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) + if err != nil { + return err + } + defer f.Close() + _, err = f.Write(append(line, '\n')) + return err +} + +func appendTaskTrajectory(storeDir, id string, step Step) error { trajectoryWriter.Lock() defer trajectoryWriter.Unlock() order, known := trajectoryWriter.orders[storeDir] @@ -256,3 +279,12 @@ func observationHead(text string) string { } return text[:cut] } + +var taskTrajectoryOrdering atomic.Bool + +// RecordTaskTrajectoryOrder is enabled only in the hidden task-store child. +// Its cleanup also keeps in-process command fixtures from changing later runs. +func RecordTaskTrajectoryOrder() func() { + previous := taskTrajectoryOrdering.Swap(true) + return func() { taskTrajectoryOrdering.Store(previous) } +} From dcb0ca962de8a9f73aeaf4ca356c489586cb03cf Mon Sep 17 00:00:00 2001 From: Abir Abbas <abirabbas1998@gmail.com> Date: Fri, 30 Oct 2026 22:38:48 -0400 Subject: [PATCH 17/34] manual: tasks go elsewhere only by choice, and serve takes them with --take-tasks Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/manual/chat/commands.md | 2 +- internal/manual/chat/how-tasks-run.md | 10 +++--- internal/manual/chat/keys.md | 4 +-- .../chat/reaching-this-machine-without-ssh.md | 3 +- internal/manual/chat/settings.md | 9 +++-- .../manual/chat/tasks-on-another-computer.md | 36 +++++++++++-------- internal/manual/chat_test.go | 4 ++- 7 files changed, 41 insertions(+), 27 deletions(-) diff --git a/internal/manual/chat/commands.md b/internal/manual/chat/commands.md index 04c8398f77..d089952e2f 100644 --- a/internal/manual/chat/commands.md +++ b/internal/manual/chat/commands.md @@ -1578,7 +1578,7 @@ weekly update" — and it is proposed on a card. `/standing` and `/orders` are g ## /task — start work you can walk away from `/task --on <computer> <brief>` runs a task in its own copy on a paired computer -running `codeaf serve`, while the chat stays here. Quote shown names containing +running `codeaf serve --take-tasks`, while the chat stays here. Quote shown names containing spaces: `/task --on "mac mini" Fix the export`. `--best` or `--cheap` may go on either side of `--on`; `solo` follows the flags. `here` and `this computer` choose this computer. The flag is offered only where paired tasks are available; diff --git a/internal/manual/chat/how-tasks-run.md b/internal/manual/chat/how-tasks-run.md index d862e59342..85db2510f6 100644 --- a/internal/manual/chat/how-tasks-run.md +++ b/internal/manual/chat/how-tasks-run.md @@ -2662,7 +2662,7 @@ and stay there. ## Does a task survive a restart? For a task on another computer, reopening the chat follows the saved attempt -and brings its answer home once. Restarting `codeaf serve` on that computer +and brings its answer home once. Restarting `codeaf serve --take-tasks` on that computer follows a child still running there; a child that died is interrupted, never automatically run again. See *Tasks on another computer* for `run it here`. @@ -3207,9 +3207,11 @@ pointer — they still point at your turn, never at the parent task's own journa These arguments are optional. Bad values return a refusal the model can correct. -**`computer`** — the shown name of a paired computer that runs `codeaf serve`. -This argument is present only on a belt with paired-task support; it is absent -where no paired-task connection is available. The task needs its own independent +**`computer`** — the shown name of a paired computer that runs +`codeaf serve --take-tasks`. This argument and its model guidance appear only +while **Run tasks on** names a paired computer. At the default `this computer`, +the model gets the ordinary local tool. You can still name a computer for one +task with `/task --on`. The task needs its own independent copy, and both ends need Linux or macOS; Windows is not yet supported. Omitted uses **Run tasks on**, whose default is `this computer`. An explicit choice wins; `here` and `this computer` run locally. An unknown name returns diff --git a/internal/manual/chat/keys.md b/internal/manual/chat/keys.md index fa53d8932e..a7e91eceb6 100644 --- a/internal/manual/chat/keys.md +++ b/internal/manual/chat/keys.md @@ -2732,8 +2732,8 @@ the keyboard, and `esc` there hands it back to the list. ## Keys in the task roster and inside a room -Where paired tasks are available, `ctrl+d computer` on a proposal over an empty -draft cycles `this computer` and eligible paired computers. Accepting sends the +When **Run tasks on** names a computer or a proposal already names one, +`ctrl+d computer` on that proposal over an empty draft cycles `this computer` and eligible paired computers. Accepting sends the choice shown by `runs on <name>`. When a remote task cannot reach its computer or was interrupted, its card and page offer `h run it here`; click the card action or press `h` over an empty draft to start fresh local work. The old diff --git a/internal/manual/chat/reaching-this-machine-without-ssh.md b/internal/manual/chat/reaching-this-machine-without-ssh.md index e86e6ffb04..c3b676a9e1 100644 --- a/internal/manual/chat/reaching-this-machine-without-ssh.md +++ b/internal/manual/chat/reaching-this-machine-without-ssh.md @@ -48,7 +48,8 @@ way, use that; the two doors are compared at the bottom of this page. `codeaf serve` keeps the identity's saved keys and settings in step the way an open terminal does only when it is started with `CODEAF_SERVE_SYNC=1`, which a dev box sets. Without it, serve syncs nothing while it serves, as it always has. -On a paired Linux or macOS computer, `codeaf serve` also prints +Plain `codeaf serve` opens only the machine connection; it does not take tasks. +On a paired Linux or macOS computer, `codeaf serve --take-tasks` also prints `takes tasks from your paired computers`. Those computers can send a task with `/task --on <shown device name> <brief>` while keeping the chat and transcript where they are. This task door uses the shared identity and needs no separate diff --git a/internal/manual/chat/settings.md b/internal/manual/chat/settings.md index fb39acacb5..265b534323 100644 --- a/internal/manual/chat/settings.md +++ b/internal/manual/chat/settings.md @@ -137,10 +137,13 @@ the current chat model when no worker model is set. **Run tasks on**, immediately after **Task model** in Tasks, chooses where a task goes when you do not name a computer. It defaults to `this computer` and -lists eligible paired computers by shown name. It is absent where paired tasks -are unavailable. An explicit `/task --on`, proposal-card choice or `computer` +reads eligible paired computers by shown name only when opened. The row +is absent on unpaired, quiet or Windows homes. An explicit `/task --on`, proposal-card choice or `computer` argument wins. Quick tasks and work without an independent copy still run here. -Both ends need Linux or macOS, and the other computer must run `codeaf serve`. +Both ends need Linux or macOS, and the other computer must run `codeaf serve --take-tasks`. Plain `codeaf serve` +does not take tasks. The model's `computer` argument and a proposal's computer +line and cycle key appear only after the setting names a computer, or (for the +card) the proposal already names one. See *Tasks on another computer* for limits and `run it here`. ## Connect a service, change models or manage privacy diff --git a/internal/manual/chat/tasks-on-another-computer.md b/internal/manual/chat/tasks-on-another-computer.md index e7c24e0434..7633303c85 100644 --- a/internal/manual/chat/tasks-on-another-computer.md +++ b/internal/manual/chat/tasks-on-another-computer.md @@ -2,15 +2,17 @@ ## Can a task run on my other computer -Yes. Pair both computers with the same identity and run `codeaf serve` on the +Yes. Pair both computers with the same identity and run `codeaf serve --take-tasks` on the computer that will do the task. Both ends need Linux or macOS; Windows cannot send or receive these tasks yet. An ordinary open chat window does not take tasks. Only a task that works in its own copy can go. -Choose the computer with `/task --on <computer> <brief>`, the proposal card's -`ctrl+d computer`, the optional `computer` argument on `propose_task`, or -**Run tasks on** in `/settings` → Tasks. Use its shown device name, not its -connection name. The named choice wins over the setting; the default is +Choose the computer with `/task --on <computer> <brief>` or **Run tasks on** +in `/settings` → Tasks. Until you choose, ordinary tasks run here without +contacting other task computers. A proposal already naming a computer, or +one made with a remote default, offers `ctrl+d computer`. The +model gets the optional `computer` argument only while **Run tasks on** names +a paired computer. Use its shown device name, not its connection name. The named choice wins over the setting; the default is `this computer`. These controls are absent when paired tasks are unavailable. The chat, conversation file and transcript stay here. The task's copy goes @@ -22,7 +24,7 @@ the conversation and task, once, and also stays on that computer's ledger. ## Run a task on my mac mini -Pair your Mac mini and this computer, then run `codeaf serve` on the mini. +Pair your Mac mini and this computer, then run `codeaf serve --take-tasks` on the mini. Use its shown name, quoting names that contain spaces: ``` @@ -34,8 +36,10 @@ Use its shown name, quoting names that contain spaces: flags. `here` and `this computer` choose this computer. A proposal's `ctrl+d computer` cycles through `this computer` and eligible paired computers over an empty draft; accepting the proposal sends that choice. The line says -`runs on <name>`. The model's `computer` argument is available only where the -paired-task belt supports it. A misspelt name is refused with +`runs on <name>`. This line and cycle key are absent from ordinary local +proposals until the setting or proposal names a computer. The model's +`computer` argument is available only while **Run tasks on** names a paired +computer. A misspelt name is refused with `no paired computer is named "<name>" — your computers are: <names>`. Both computers must run Linux or macOS, and the task needs its own copy. Sending @@ -68,13 +72,13 @@ returns, the card says `running on <computer>` again and missed steps arrive in order. Reopening this chat follows the saved attempt and brings its result home once. Nothing is ever re-run by itself to recover a remote attempt. -Restarting `codeaf serve` follows a child that is still alive. A child that died +Restarting `codeaf serve --take-tasks` follows a child that is still alive. A child that died without an answer is interrupted and is never started again automatically. Stopping the task here asks the other computer to stop; its partial change comes back through the same landing road as a stopped local task. If the computer never took the task, the sentence is -`<computer> is not taking tasks right now — run codeaf serve on it`. +`<computer> is not taking tasks right now — run codeaf serve --take-tasks on it`. An older build says `<computer> runs a different codeaf that cannot take this task — update both`. A removed device is refused with `this computer was removed from your devices`. @@ -93,12 +97,13 @@ The placement words disappear and the receipt says both copies and refuses to overwrite it. A computer that dropped may still be working; a request to stop it is best effort. -## Run tasks on +## Run tasks on — when the model gets the computer argument In `/settings` → Tasks, **Run tasks on** follows **Task model**. Its hint is `where a task goes when you do not name a computer`. It offers `this computer` -(the default), then eligible paired computers by shown name. It is absent when -paired tasks cannot work here. A removed or unsupported saved choice displays +(the default), then eligible paired computers by shown name. The row reads the +computer list only when you open it. It is absent on unpaired, quiet or Windows +homes. A removed or unsupported saved choice displays `this computer`. Changing the row affects the next task, not work already running. An explicit `/task --on`, proposal-card computer choice, or model-supplied @@ -106,9 +111,10 @@ An explicit `/task --on`, proposal-card computer choice, or model-supplied tasks and other work without an independent copy run here even when the setting names another computer, without a special placement sentence. -## codeaf serve takes tasks from paired computers +## codeaf serve --take-tasks takes tasks from paired computers -Run `codeaf serve` on the paired computer that should take tasks. It prints +Plain `codeaf serve` opens only the machine connection and does not take tasks. +Run `codeaf serve --take-tasks` on the paired computer that should take tasks. It prints `takes tasks from your paired computers`. Pairing with the same identity is the permission; no separate machine-access grant is needed for tasks. Quiet or unpaired homes do not offer this door. Linux and macOS are supported on both diff --git a/internal/manual/chat_test.go b/internal/manual/chat_test.go index 479d210fbf..bda0687c0f 100644 --- a/internal/manual/chat_test.go +++ b/internal/manual/chat_test.go @@ -40,7 +40,9 @@ func TestTheChatManualAnswersTheQuestionsPeopleAsk(t *testing.T) { {"my other computer stopped answering", "tasks-on-another-computer"}, {"run it here", "tasks-on-another-computer"}, {"run tasks on", "tasks-on-another-computer"}, - {"codeaf serve takes tasks", "tasks-on-another-computer"}, + {"codeaf serve --take-tasks takes tasks", "tasks-on-another-computer"}, + {"does plain codeaf serve take tasks", "tasks-on-another-computer"}, + {"when does the model get the computer argument", "tasks-on-another-computer"}, {"why did my task run here instead", "tasks-on-another-computer"}, {"can this conversation start other tasks while one runs on another computer", "tasks-on-another-computer"}, {"why didn't my task's changes show up in my folder", "how-tasks-run"}, From 5055d9366a93ccad45a8b0a5d6590d22b02d5f64 Mon Sep 17 00:00:00 2001 From: Abir Abbas <abirabbas1998@gmail.com> Date: Fri, 30 Oct 2026 23:18:04 -0400 Subject: [PATCH 18/34] devbox: take tasks when the box's installed build can The boot script passes --take-tasks to serve only when the installed build's serve --help lists it. A box can follow a channel whose build predates the flag, and serve refuses a flag it does not know, which would leave the box serving nothing; such a box serves as before and picks the flag up on the restart after its channel ships it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/devbox/boot_test.go | 68 +++++++++++++++++++ internal/devbox/terraform/boot.sh | 10 ++- .../chat/a-dev-box-on-your-own-railway.md | 14 ++++ internal/manual/chat_test.go | 1 + 4 files changed, 92 insertions(+), 1 deletion(-) diff --git a/internal/devbox/boot_test.go b/internal/devbox/boot_test.go index 9185a2a6e9..533726ac17 100644 --- a/internal/devbox/boot_test.go +++ b/internal/devbox/boot_test.go @@ -7,11 +7,13 @@ import ( "os/exec" "path/filepath" "slices" + "strconv" "strings" "testing" "time" "github.com/Agent-Field/codeaf/internal/syncsetup" + "github.com/Agent-Field/codeaf/internal/taskdoor" ) // R6.2: the first install pins its release, retries unpinned, and later boots follow the channel. @@ -367,3 +369,69 @@ func TestDevboxBootAsksServeToKeepStateInStep(t *testing.T) { t.Fatalf("boot.sh must export %q before it starts serve", strings.TrimSpace(export)) } } + +// Stability: a box takes tasks only when its installed build's serve lists the +// flag, and a build that predates it, or whose help fails, still serves. +func TestDevboxBootTakesTasksOnlyWhenServeListsTheFlag(t *testing.T) { + flag := "--" + taskdoor.TakeTasksFlag + for _, row := range []struct { + name, help string + helpExit int + want bool + }{ + {name: "build-lists-the-flag", help: "flags:\n " + flag + "\n take tasks from your paired computers\n", want: true}, + {name: "build-predates-the-flag", help: "flags:\n --via string\n --workspace string\n"}, + {name: "help-fails", help: "flag provided but not defined: -help\n", helpExit: 2}, + } { + t.Run(row.name, func(t *testing.T) { + data := t.TempDir() + bin := filepath.Join(data, "home", ".codeaf", "bin") + if err := os.MkdirAll(bin, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(data, ".codeaf-devbox-joined"), nil, 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(data, "help"), []byte(row.help), 0o600); err != nil { + t.Fatal(err) + } + fake := `#!/bin/bash +if [[ "$1" == serve && "$2" == --help ]]; then + cat "$CODEAF_DEVBOX_DATA_ROOT/help" + exit "$HELP_EXIT" +fi +printf '%s\n' "$*" >> "$CODEAF_DEVBOX_DATA_ROOT/calls" +` + if err := os.WriteFile(filepath.Join(bin, "codeaf"), []byte(fake), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(bin, "curl"), []byte("#!/bin/sh\nexit 22\n"), 0o700); err != nil { + t.Fatal(err) + } + script, err := module.ReadFile("terraform/boot.sh") + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, "bash") + cmd.Stdin = strings.NewReader(string(script)) + cmd.Env = append(os.Environ(), "CODEAF_TELEMETRY=off", "DO_NOT_TRACK=1", "CODEAF_DEVBOX_DATA_ROOT="+data, "CODEAF_DEVBOX_BINARY=codeaf", "CODEAF_DEVBOX_NAME=box", "CODEAF_DEVBOX_VIA=https://relay.example", "CODEAF_DEVBOX_INSTALL_URL=https://installer.example/codeaf", "CODEAF_DEVBOX_CODE=", "CODEAF_DEVBOX_REMOTE_KEY=", "CODEAF_DEVBOX_REMOTE_DEVICES=", "HELP_EXIT="+strconv.Itoa(row.helpExit)) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("boot: %v %s", err, out) + } + calls, err := os.ReadFile(filepath.Join(data, "calls")) + if err != nil { + t.Fatal(err) + } + serve := "serve --workspace " + filepath.Join(data, "home", "work") + " --via https://relay.example" + want := serve + "\n" + if row.want { + want = serve + " " + flag + "\n" + } + if string(calls) != want { + t.Fatalf("the box ran %q, want %q", calls, want) + } + }) + } +} diff --git a/internal/devbox/terraform/boot.sh b/internal/devbox/terraform/boot.sh index 841e5e7a9d..1d7a691e99 100644 --- a/internal/devbox/terraform/boot.sh +++ b/internal/devbox/terraform/boot.sh @@ -80,5 +80,13 @@ say 'serving the work folder' # A box only serves, so it asks serve to keep the identity's saved keys, # settings and presence in step; builds that predate the variable ignore it. export CODEAF_SERVE_SYNC=1 -# `--take-tasks` joins this line once serve ships it; a build without it would refuse the flag. +# A box also takes the identity's tasks, but only from a build whose serve lists +# `--take-tasks`: a box can follow a channel whose build predates the flag, and +# serve refuses a flag it does not know, which would leave the box serving +# nothing. Two exec lines rather than an array, because `set -u` rejects an +# empty array on the bash 3.2 a Mac runs this script's test with. +serve_help="$("$CODEAF_DEVBOX_BINARY" serve --help 2>&1 </dev/null || true)" +if [[ "$serve_help" == *--take-tasks* ]]; then + exec "$CODEAF_DEVBOX_BINARY" serve --workspace "$HOME/work" --via "$CODEAF_DEVBOX_VIA" --take-tasks +fi exec "$CODEAF_DEVBOX_BINARY" serve --workspace "$HOME/work" --via "$CODEAF_DEVBOX_VIA" diff --git a/internal/manual/chat/a-dev-box-on-your-own-railway.md b/internal/manual/chat/a-dev-box-on-your-own-railway.md index 93d715430b..839678dfe4 100644 --- a/internal/manual/chat/a-dev-box-on-your-own-railway.md +++ b/internal/manual/chat/a-dev-box-on-your-own-railway.md @@ -148,6 +148,20 @@ there is no terminal to approve it. It does not wait for a yes/no answer there. An unadmitted key with a saved pin is also refused. There is no unattended command to admit another computer yet; use the creating laptop. +## Send a task to my dev box + +A dev box also takes tasks. It starts `codeaf serve --take-tasks` whenever its +installed build lists that flag, so a chat on your laptop can run one task on the +box while the conversation and transcript stay on the laptop: +`/task --on <name> <brief>` names it for one task, and Settings → Run tasks on +sends every task that can go. The box's synced provider keys pay, and the spend +appears on the laptop too. A box whose build predates the flag starts plain +`codeaf serve` and takes no tasks; sending one says +`<name> is not taking tasks right now — run codeaf serve --take-tasks on it`, and +the box picks the flag up when its channel's build has it and it restarts. See +*Tasks on another computer* for what can go and what happens when a computer +stops answering. + ## How do I remove my dev box or stop paying for it? Run `codeaf devbox down <name>` with the Railway token set. It asks diff --git a/internal/manual/chat_test.go b/internal/manual/chat_test.go index bda0687c0f..9b8593a6d2 100644 --- a/internal/manual/chat_test.go +++ b/internal/manual/chat_test.go @@ -44,6 +44,7 @@ func TestTheChatManualAnswersTheQuestionsPeopleAsk(t *testing.T) { {"does plain codeaf serve take tasks", "tasks-on-another-computer"}, {"when does the model get the computer argument", "tasks-on-another-computer"}, {"why did my task run here instead", "tasks-on-another-computer"}, + {"send a task to my dev box", "a-dev-box-on-your-own-railway"}, {"can this conversation start other tasks while one runs on another computer", "tasks-on-another-computer"}, {"why didn't my task's changes show up in my folder", "how-tasks-run"}, {"can I run codeaf on my own server", "a-dev-box-on-your-own-railway"}, From cba3ad6383adf416c0e9d4e2f19c97ffb840a2d5 Mon Sep 17 00:00:00 2001 From: Abir Abbas <abirabbas1998@gmail.com> Date: Fri, 30 Oct 2026 23:18:04 -0400 Subject: [PATCH 19/34] serve: name --take-tasks in the usage lines Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- cmd/codeaf/main.go | 2 +- internal/manual/chat/reaching-this-machine-without-ssh.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/codeaf/main.go b/cmd/codeaf/main.go index ae116ac90c..e6d74dae86 100644 --- a/cmd/codeaf/main.go +++ b/cmd/codeaf/main.go @@ -613,7 +613,7 @@ Housekeeping — changes state on disk or on the network approve a new device; or use or show a six-digit code for your chats codeaf devbox up | down <name> · status [<name>] [--yes] [--dir D] an always-on computer of yours on your own Railway account - codeaf serve [--workspace path] [--via url] + codeaf serve [--workspace path] [--via url] [--take-tasks] be reachable from your other devices without ssh, with a pairing code codeaf devices | codeaf cluster status | lead list your devices and the ones that can use this machine diff --git a/internal/manual/chat/reaching-this-machine-without-ssh.md b/internal/manual/chat/reaching-this-machine-without-ssh.md index c3b676a9e1..966f88e073 100644 --- a/internal/manual/chat/reaching-this-machine-without-ssh.md +++ b/internal/manual/chat/reaching-this-machine-without-ssh.md @@ -62,7 +62,7 @@ Run it on the machine that owns the work — the one with the files, the API key things you have set up: ``` -codeaf serve [--workspace path] [--via https://…] +codeaf serve [--workspace path] [--via https://…] [--take-tasks] ``` It prints, exactly: From 95b0172057ea4538ccbf1dbd31ce77447a538886 Mon Sep 17 00:00:00 2001 From: Abir Abbas <abirabbas1998@gmail.com> Date: Fri, 30 Oct 2026 23:18:04 -0400 Subject: [PATCH 20/34] manual: a remote task's private apply has the same Mac limit as a local one Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/manual/chat/tasks-on-another-computer.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/internal/manual/chat/tasks-on-another-computer.md b/internal/manual/chat/tasks-on-another-computer.md index 7633303c85..23896fac54 100644 --- a/internal/manual/chat/tasks-on-another-computer.md +++ b/internal/manual/chat/tasks-on-another-computer.md @@ -19,6 +19,9 @@ The chat, conversation file and transcript stay here. The task's copy goes encrypted to the other computer, and its change comes home through the ordinary landing road, including its commits. With private task copies on, use `/task review <number> work` then `/task apply <number> work` here instead. +That apply is the same one a task that ran here uses, with the same limits: on +a Mac it refuses a project whose files belong to a group other than yours, +saying `portable apply cannot preserve a different owner or group`. The other computer uses its own provider keys; its spend appears here against the conversation and task, once, and also stays on that computer's ledger. From 395d01c48742c0013e54dc1596de8ed647b96af6 Mon Sep 17 00:00:00 2001 From: Abir Abbas <abirabbas1998@gmail.com> Date: Fri, 30 Oct 2026 23:20:06 -0400 Subject: [PATCH 21/34] changes: a task can run on another paired computer Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../2085-task-on-another-computer.md | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 docs/changes/unreleased/2085-task-on-another-computer.md diff --git a/docs/changes/unreleased/2085-task-on-another-computer.md b/docs/changes/unreleased/2085-task-on-another-computer.md new file mode 100644 index 0000000000..94e62d9b82 --- /dev/null +++ b/docs/changes/unreleased/2085-task-on-another-computer.md @@ -0,0 +1,25 @@ +--- +kind: added +title: Run a task on another paired computer while the chat stays here +pr: 2085 +surface: [chat, engine, remote, docs] +invalidates: + - "A task always ran on the computer the chat was on. It can now run on a paired Linux or macOS computer that runs `codeaf serve --take-tasks`, chosen with `/task --on <computer>`, the proposal card's computer choice, or the Run tasks on setting; the chat, conversation file and transcript stay here." + - "codeaf serve only made a machine reachable for `--at`. With `--take-tasks` it also takes tasks from the identity's paired computers; plain `codeaf serve` still takes none and prints what it did before." + - "propose_task had no way to name a computer. It gains an optional `computer` argument, present only after the Run tasks on setting names a paired computer; with the setting at this computer the schema is unchanged." + - "A dev box only served `--at` chats. Its boot now passes `--take-tasks` when the box's installed build lists the flag, so a task can be sent to it." +--- + +The task's copy travels as an encrypted, content-addressed delta through the +relay, the other computer runs it with its own provider keys, and steps, phase +and spend stream back here; the change lands through the ordinary landing road, +or Review and Apply with private task copies on. A computer that stops +answering is shown as possibly still working with `run it here`; nothing re-runs +by itself, and an abandoned attempt's late result never lands. Only a task that +works in its own copy can go, both ends must be Linux or macOS, and a remote +task runs alone in its conversation. + +Everything is opt-in. With defaults left alone, pairing adds no relay traffic, +and a local task's brief, records, the proposal card and the model's tool are +byte-identical to before; the Run tasks on settings row is the only new thing a +paired person sees. From 3c532602dce17e5044d921010c2e7c656587e95b Mon Sep 17 00:00:00 2001 From: Abir Abbas <abirabbas1998@gmail.com> Date: Sat, 31 Oct 2026 01:14:46 -0400 Subject: [PATCH 22/34] fix: hide Run tasks on from the settings tool without task computers Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/config/settings.go | 17 +++++++++ internal/session/task_settings_r8_test.go | 46 +++++++++++++++++++++++ internal/session/tools_settings.go | 3 ++ internal/session/tools_settings_test.go | 1 + 4 files changed, 67 insertions(+) create mode 100644 internal/session/task_settings_r8_test.go diff --git a/internal/config/settings.go b/internal/config/settings.go index 10e7e06a07..2f6938090f 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -1789,6 +1789,9 @@ type SettingGroup struct { // own: the running model slots and the surface's own divider preference. type SettingsOptions struct { ProfileDir string + // Include omits controls unavailable to the caller from discovery and writes. + // Nil keeps the full registry for callers that supply their own presentation. + Include func(key string) bool // ModelValue and SetModel front the model slots. They stay in the chat // prefs file; the registry does not migrate them. @@ -1842,9 +1845,23 @@ type Settings struct { // divider has nowhere to be written without [SettingsOptions.SaveSplitPct], and // a read-only divider is not a row somebody can read — it is a control whose // only behaviour is to refuse. +// +// [SettingsOptions.Include] is the caller's own narrowing, applied after the +// build: a control the caller cannot offer at all (Run tasks on, in a +// conversation with no task computers) is left out of discovery and writes +// alike, rather than listed and refused. func NewSettings(options SettingsOptions) *Settings { registry := &Settings{options: options} registry.rows = registry.build() + if options.Include != nil { + rows := registry.rows[:0] + for _, row := range registry.rows { + if options.Include(row.Key) { + rows = append(rows, row) + } + } + registry.rows = rows + } for index := range registry.rows { registry.rows[index].write = registry.announce(registry.rows[index].Key, registry.rows[index].write) } diff --git a/internal/session/task_settings_r8_test.go b/internal/session/task_settings_r8_test.go new file mode 100644 index 0000000000..433fb28602 --- /dev/null +++ b/internal/session/task_settings_r8_test.go @@ -0,0 +1,46 @@ +package session + +import ( + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/config" +) + +// R7-1 and S10 keep unavailable placement out of both settings hands. +func TestTheSettingsToolSaysNothingAboutRunTasksOnWithoutTaskComputers(t *testing.T) { + a, profile := settingsAgent(t) + read, write := settingsHands(t, a) + text, bad := callSetting(t, read, map[string]string{}) + if bad || strings.Contains(text, "Run tasks on") || strings.Contains(text, config.KeyTaskRunsOn) { + t.Fatalf("unsupported listing: %s", text) + } + for _, tool := range []string{"read", "write"} { + hand := read + if tool == "write" { + hand = write + } + args := map[string]string{"key": config.KeyTaskRunsOn} + if tool == "write" { + args["value"] = "dev_0123456789abcdef0123456789abcdef" + } + text, bad = callSetting(t, hand, args) + if !bad { + t.Fatalf("unsupported %s accepted: %s", tool, text) + } + } + if config.TaskRunsOnAt(profile) != "" { + t.Fatal("unsupported write changed the preference") + } +} + +// R7-1 retains the paired conversation's opt-in setting. +func TestTheSettingsToolListsRunTasksOnWhenTheConversationHasTaskComputers(t *testing.T) { + a, _ := settingsAgent(t) + a.config.TaskComputers = &testTaskComputers{} + read, _ := settingsHands(t, a) + text, bad := callSetting(t, read, map[string]string{}) + if bad || !strings.Contains(text, "Run tasks on") { + t.Fatalf("paired listing: %s", text) + } +} diff --git a/internal/session/tools_settings.go b/internal/session/tools_settings.go index a9206e9b0d..4172ab1e8e 100644 --- a/internal/session/tools_settings.go +++ b/internal/session/tools_settings.go @@ -115,6 +115,9 @@ func (a *Agent) settingsTools() []bare.Tool { // the LIST of rows, which changes only when the binary does. registry := config.NewSettings(config.SettingsOptions{ ProfileDir: dir, + Include: func(key string) bool { + return key != config.KeyTaskRunsOn || a.config.TaskComputers != nil + }, // THE ONE LIVE SEAM, AND IT IS A READER. Without it the conversation's // model row reads BLANK — the registry has no way to ask a running // session what it is on — and a sheet whose first row is empty is a diff --git a/internal/session/tools_settings_test.go b/internal/session/tools_settings_test.go index 67a5a3ce42..f07423d3ab 100644 --- a/internal/session/tools_settings_test.go +++ b/internal/session/tools_settings_test.go @@ -96,6 +96,7 @@ func TestTheSettingsPairIsAbsentInsideATaskAndPresentWithTheDefaultProfile(t *te // write tool takes, grouped the way the sheet groups them. func TestSettingsListsEveryRowByItsRegistryKey(t *testing.T) { agent, profile := settingsAgent(t) + agent.config.TaskComputers = testTaskComputers{} agent.model = "anthropic/claude-opus-5" read, _ := settingsHands(t, agent) text, isError := callSetting(t, read, map[string]string{}) From f0ce8bb74a6dc769f07949c085c80aff81c428aa Mon Sep 17 00:00:00 2001 From: Abir Abbas <abirabbas1998@gmail.com> Date: Sat, 31 Oct 2026 01:14:46 -0400 Subject: [PATCH 23/34] fix: refuse a finished attempt started again and a removed computer on a live task connection Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/taskdoor/attempts.go | 52 +++++++++++- internal/taskdoor/finished.go | 50 ++++++++++++ internal/taskdoor/finished_test.go | 38 +++++++++ internal/taskdoor/hello.go | 8 +- internal/taskdoor/review_r8_test.go | 118 ++++++++++++++++++++++++++++ internal/taskdoor/serve.go | 60 +++++++++++--- 6 files changed, 308 insertions(+), 18 deletions(-) create mode 100644 internal/taskdoor/finished.go create mode 100644 internal/taskdoor/finished_test.go create mode 100644 internal/taskdoor/review_r8_test.go diff --git a/internal/taskdoor/attempts.go b/internal/taskdoor/attempts.go index 891005400e..eba99660f0 100644 --- a/internal/taskdoor/attempts.go +++ b/internal/taskdoor/attempts.go @@ -82,6 +82,7 @@ type attemptStore struct { clock Clock mu sync.Mutex attempts map[string]*Attempt + finished map[string]time.Time } func validAttempt(id string) bool { @@ -103,7 +104,13 @@ func openAttempts(root string, clock Clock) (*attemptStore, error) { if err != nil { return nil, err } - store := &attemptStore{root: root, clock: clock, attempts: map[string]*Attempt{}} + store := &attemptStore{root: root, clock: clock, attempts: map[string]*Attempt{}, finished: map[string]time.Time{}} + if err := readJSON(filepath.Join(root, "finished.json"), &store.finished); err != nil && !errors.Is(err, os.ErrNotExist) { + return nil, err + } + if store.finished == nil { + store.finished = map[string]time.Time{} + } for _, entry := range entries { if !validAttempt(entry.Name()) { continue @@ -112,15 +119,48 @@ func openAttempts(root string, clock Clock) (*attemptStore, error) { return nil, fmt.Errorf("taskdoor: attempt %s is not a folder", entry.Name()) } a := &Attempt{ID: entry.Name(), Dir: filepath.Join(root, entry.Name()), changed: make(chan struct{}), clock: clock} + _, finished := store.finished[a.ID] + stateErr := readJSON(filepath.Join(a.Dir, "state.json"), &a.state) + // A completed acknowledgement can be interrupted halfway through RemoveAll. + // Its durable tombstone outranks a now-missing state or request file. + if finished && (errors.Is(stateErr, os.ErrNotExist) || stateErr == nil && a.state.Phase != "running") { + if err := os.RemoveAll(a.Dir); err != nil { + return nil, err + } + continue + } + if stateErr != nil { + return nil, stateErr + } if err := readJSON(filepath.Join(a.Dir, "request.json"), &a.Request); err != nil { + if finished && errors.Is(err, os.ErrNotExist) { + if err := os.RemoveAll(a.Dir); err != nil { + return nil, err + } + continue + } return nil, err } - if err := readJSON(filepath.Join(a.Dir, "state.json"), &a.state); err != nil { - return nil, err + if finished { + a.state.Abandoned = true } a.removed = a.state.Abandoned + if a.removed { + if err := store.rememberFinished(a.ID); err != nil { + return nil, err + } + if err := atomicJSON(filepath.Join(a.Dir, "state.json"), a.state); err != nil { + return nil, err + } + } events, err := readEvents(a.Dir) if err != nil { + if finished && errors.Is(err, os.ErrNotExist) { + if err := os.RemoveAll(a.Dir); err != nil { + return nil, err + } + continue + } return nil, err } // A crash after the journal's rename but before its derived state write @@ -172,6 +212,9 @@ func (s *attemptStore) admit(id string, request Start, busy string) (*Attempt, b } s.mu.Lock() defer s.mu.Unlock() + if _, finished := s.finished[id]; finished { + return nil, false, errors.New("that task has already finished") + } if a := s.attempts[id]; a != nil { a.mu.Lock() removed := a.removed @@ -223,6 +266,9 @@ func (s *attemptStore) forget(id string) error { } a.mu.Lock() defer a.mu.Unlock() + if err := s.rememberFinished(id); err != nil { + return err + } // A real child can still write its private run after SIGINT. Keep a // durable tombstone until it is joined, including across a serving restart. if a.state.Phase == "running" && (a.state.PID > 0 || !a.state.LaunchStartedAt.IsZero() || !a.state.StopRequestedAt.IsZero()) { diff --git a/internal/taskdoor/finished.go b/internal/taskdoor/finished.go new file mode 100644 index 0000000000..4333a5476d --- /dev/null +++ b/internal/taskdoor/finished.go @@ -0,0 +1,50 @@ +package taskdoor + +import ( + "errors" + "os" + "path/filepath" + "sort" + "time" +) + +// MaxFinishedAttempts bounds durable replay protection after copy cleanup. +const MaxFinishedAttempts = 1000 + +func (s *attemptStore) rememberFinished(id string) error { + next := make(map[string]time.Time, len(s.finished)+1) + for key, at := range s.finished { + next[key] = at + } + next[id] = s.clock.Now() + if len(next) > MaxFinishedAttempts { + ids := make([]string, 0, len(next)) + for key := range next { + if key != id { + ids = append(ids, key) + } + } + sort.Slice(ids, func(i, j int) bool { + if next[ids[i]].Equal(next[ids[j]]) { + return ids[i] < ids[j] + } + return next[ids[i]].Before(next[ids[j]]) + }) + for _, key := range ids[:len(next)-MaxFinishedAttempts] { + delete(next, key) + } + } + if err := atomicJSON(filepath.Join(s.root, "finished.json"), next); err != nil { + return err + } + // The tombstone's directory entry must survive before the bulky attempt is removed. + directory, err := os.Open(s.root) + if err != nil { + return err + } + if err = errors.Join(directory.Sync(), directory.Close()); err != nil { + return err + } + s.finished = next + return nil +} diff --git a/internal/taskdoor/finished_test.go b/internal/taskdoor/finished_test.go new file mode 100644 index 0000000000..4fa079841d --- /dev/null +++ b/internal/taskdoor/finished_test.go @@ -0,0 +1,38 @@ +package taskdoor + +import ( + "fmt" + "strings" + "testing" + "time" +) + +// R7-3 keeps the compact newest-attempt bound durable across a store restart. +func TestAcknowledgedAttemptReplayProtectionIsBounded(t *testing.T) { + clock := wallClock{} + store, err := openAttempts(t.TempDir(), clock) + if err != nil { + t.Fatal(err) + } + now := clock.Now() + for n := 0; n < MaxFinishedAttempts; n++ { + store.finished[fmt.Sprintf("att_%016x", n)] = now.Add(time.Duration(n-MaxFinishedAttempts) * time.Second) + } + newest := fmt.Sprintf("att_%016x", MaxFinishedAttempts) + if err := store.rememberFinished(newest); err != nil { + t.Fatal(err) + } + reopened, err := openAttempts(store.root, clock) + if err != nil { + t.Fatal(err) + } + if len(reopened.finished) != MaxFinishedAttempts { + t.Fatalf("retained %d acknowledgements", len(reopened.finished)) + } + if _, old := reopened.finished["att_0000000000000000"]; old { + t.Fatal("oldest acknowledgement was not compacted") + } + if _, _, err := reopened.admit(newest, Start{}, "busy"); err == nil || !strings.Contains(err.Error(), "already finished") { + t.Fatalf("newest replay = %v", err) + } +} diff --git a/internal/taskdoor/hello.go b/internal/taskdoor/hello.go index b498bbda5d..90e851b0a2 100644 --- a/internal/taskdoor/hello.go +++ b/internal/taskdoor/hello.go @@ -98,10 +98,10 @@ func refuse(tunnel *pair.Tunnel, reason string) error { } return WriteFrame(tunnel, frame) } -func acceptHello(ctx context.Context, tunnel *pair.Tunnel, id identity.Identity, dev identity.Dev, roster *rosterCache, name string) error { +func acceptHello(ctx context.Context, tunnel *pair.Tunnel, id identity.Identity, dev identity.Dev, roster *rosterCache, name string) (string, error) { frame, err := ReadFrame(tunnel) if err != nil { - return err + return "", err } var hello Hello reason := "" @@ -124,7 +124,7 @@ func acceptHello(ctx context.Context, tunnel *pair.Tunnel, id identity.Identity, } if reason != "" { _ = refuse(tunnel, reason) - return errors.New(reason) + return "", errors.New(reason) } - return sendHello(tunnel, dev) + return hello.Cert.DeviceID(), sendHello(tunnel, dev) } diff --git a/internal/taskdoor/review_r8_test.go b/internal/taskdoor/review_r8_test.go new file mode 100644 index 0000000000..61ceb63f13 --- /dev/null +++ b/internal/taskdoor/review_r8_test.go @@ -0,0 +1,118 @@ +package taskdoor_test + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/taskdoor" +) + +// R7-3 and contract 12 forbid re-executing an acknowledged attempt, even after restart. +func TestAnAttemptStartedAgainAfterItsAckNeverRunsAgain(t *testing.T) { + r := newRig(t) + stop := r.serve(r.config()) + c := r.dial() + id := attempt(801) + send(t, c, id, request()) + receive(t, c, "started") + send(t, c, id, taskdoor.Watch{}) + receive(t, c, "finished") + send(t, c, id, taskdoor.Ack{Disposition: "landed"}) + receive(t, c, "acked") + check := func() { + t.Helper() + send(t, c, id, request()) + var no taskdoor.Refused + decode(t, receive(t, c, "refused"), &no) + if !strings.Contains(no.Reason, "already finished") { + t.Fatalf("reason = %q", no.Reason) + } + if r.executor.calls.Load() != 1 { + t.Fatal("acknowledged task ran again") + } + } + check() + _ = c.Close() + stop() + // A killed cleanup can leave only a directory fragment behind the tombstone. + if err := os.MkdirAll(filepath.Join(r.root, id), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(r.root, id, "cleanup-fragment"), []byte("kept until restart"), 0600); err != nil { + t.Fatal(err) + } + r.serve(r.config()) + c = r.dial() + check() +} + +// R7-4 and contract 13 recheck authority on an already authenticated connection. +func TestARevokedComputerCannotStartWorkOnAConnectionItAlreadyHolds(t *testing.T) { + r := newRig(t) + clock := newClock() + cfg := r.config() + cfg.Clock = clock + r.serve(cfg) + c := r.dial() + r.rosterMu.Lock() + r.active[r.a.ID()] = false + r.rosterMu.Unlock() + clock.advance(taskdoor.RosterFor + time.Second) + send(t, c, attempt(802), request()) + var no taskdoor.Refused + decode(t, receive(t, c, "refused"), &no) + if no.Reason != taskdoor.RemovedComputerLine { + t.Fatalf("reason = %q", no.Reason) + } + if _, err := c.Receive(); err == nil { + t.Fatal("revoked connection stayed open") + } + if r.executor.calls.Load() != 0 { + t.Fatal("revoked device ran work") + } +} + +// R7-4 also fences the live replay road after the bounded permission expires. +func TestARevokedComputerCannotKeepWatchingAnExistingAttempt(t *testing.T) { + r := newRig(t) + clock := newClock() + cfg := r.config() + cfg.Clock = clock + r.executor.run = func(ctx context.Context, _ *taskdoor.Attempt, j taskdoor.Journal) error { + if err := j.Append(taskdoor.Phase{Words: "running"}); err != nil { + return err + } + <-ctx.Done() + return ctx.Err() + } + r.serve(cfg) + c := r.dial() + id := attempt(803) + send(t, c, id, request()) + receive(t, c, "started") + send(t, c, id, taskdoor.Watch{}) + receive(t, c, "phase") + r.rosterMu.Lock() + r.active[r.a.ID()] = false + r.rosterMu.Unlock() + // Keep activity current while advancing the permission lifetime, so an + // unrelated keepalive timeout cannot satisfy the revocation check. + for n := 0; n < 3; n++ { + clock.advance(taskdoor.PingEvery) + send(t, c, id, taskdoor.Watch{}) + receive(t, c, "phase") + } + clock.advance(taskdoor.PingEvery) + var no taskdoor.Refused + decode(t, receive(t, c, "refused"), &no) + if no.Reason != taskdoor.RemovedComputerLine { + t.Fatalf("reason = %q", no.Reason) + } + if _, err := c.Receive(); err == nil { + t.Fatal("revoked watcher stayed connected") + } +} diff --git a/internal/taskdoor/serve.go b/internal/taskdoor/serve.go index 2d104bb31f..ba1fa8e1ca 100644 --- a/internal/taskdoor/serve.go +++ b/internal/taskdoor/serve.go @@ -147,7 +147,8 @@ func (d *door) answer(ctx context.Context, stream io.ReadWriteCloser, key *ecdh. if err != nil { return } - if err := acceptHello(handshakeCtx, tunnel, d.config.Identity, d.config.Device, d.roster, d.name); err != nil { + device, err := acceptHello(handshakeCtx, tunnel, d.config.Identity, d.config.Device, d.roster, d.name) + if err != nil { return } endHandshake() @@ -162,6 +163,9 @@ func (d *door) answer(ctx context.Context, stream io.ReadWriteCloser, key *ecdh. if err != nil { return } + if !d.allowConnection(ctx, conn, device, frame.Attempt) { + return + } reply := func(payload any) error { return conn.Send(frame.Attempt, payload) } switch frame.Type { case "start": @@ -199,7 +203,7 @@ func (d *door) answer(ctx context.Context, stream io.ReadWriteCloser, key *ecdh. } watch.close() conn.startWatch() - watch = d.startWatch(ctx, conn, a, request.From) + watch = d.startWatch(ctx, conn, a, request.From, device) case "stop": if d.store.get(frame.Attempt) == nil { if reply(Unknown{}) != nil { @@ -284,14 +288,22 @@ func (d *door) execute(ctx context.Context, a *Attempt, adopt bool) { } }() } -func (d *door) watch(ctx context.Context, conn *Conn, a *Attempt, from uint64) { +func (d *door) watch(ctx context.Context, conn *Conn, a *Attempt, from uint64, device string) { + check := d.clock.NewTicker(time.Second) + defer check.Stop() for { + if !d.allowConnection(ctx, conn, device, a.ID) { + return + } frames, changed, finished, err := a.after(from) if err != nil { _ = conn.Send(a.ID, Unknown{}) return } for _, frame := range frames { + if !d.allowConnection(ctx, conn, device, a.ID) { + return + } select { case <-ctx.Done(): return @@ -306,12 +318,21 @@ func (d *door) watch(ctx context.Context, conn *Conn, a *Attempt, from uint64) { conn.watching.Store(false) return } - select { - case <-ctx.Done(): - return - case <-conn.done: - return - case <-changed: + waiting: + for { + select { + case <-ctx.Done(): + return + case <-conn.done: + return + case <-changed: + break waiting + case <-check.C(): + // Permission refresh must not turn a quiet watch into a journal scan every second. + if !d.allowConnection(ctx, conn, device, a.ID) { + return + } + } } } } @@ -329,9 +350,26 @@ func (w *doorWatch) close() { <-w.done } } -func (d *door) startWatch(ctx context.Context, conn *Conn, a *Attempt, from uint64) *doorWatch { +func (d *door) startWatch(ctx context.Context, conn *Conn, a *Attempt, from uint64, device string) *doorWatch { ctx, cancel := context.WithCancel(ctx) watch := &doorWatch{cancel: cancel, done: make(chan struct{})} - go func() { defer close(watch.done); defer cancel(); d.watch(ctx, conn, a, from) }() + go func() { defer close(watch.done); defer cancel(); d.watch(ctx, conn, a, from, device) }() return watch } + +// A handshake establishes identity; the bounded roster still owns continuing permission. +func (d *door) allowConnection(ctx context.Context, conn *Conn, device, attempt string) bool { + active, err := d.roster.allows(ctx, device) + reason := "" + if err != nil { + reason = "could not check your devices right now" + } else if !active { + reason = RemovedComputerLine + } + if reason == "" { + return true + } + _ = conn.Send(attempt, Refused{Reason: reason}) + _ = conn.Close() + return false +} From 40b05fb409fe4bbe2107807c0673e6baed9eb714 Mon Sep 17 00:00:00 2001 From: Abir Abbas <abirabbas1998@gmail.com> Date: Sat, 31 Oct 2026 01:14:46 -0400 Subject: [PATCH 24/34] fix: keep an edit made while a result installs, and leave stop free during downloads Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/taskdoor/copy.go | 178 +++++++++++++++------ internal/taskdoor/copy_test.go | 46 ++++-- internal/taskdoor/engine_fixture_test.go | 30 ++++ internal/taskdoor/executor.go | 12 +- internal/taskdoor/performance_r8_test.go | 49 ++++++ internal/taskdoor/relative_copy_r8_test.go | 63 ++++++++ internal/taskplace/engine.go | 22 +-- internal/taskplace/engine_fixture_test.go | 30 ++++ internal/taskplace/engine_test.go | 6 +- internal/taskplace/results_r8_test.go | 174 ++++++++++++++++++++ 10 files changed, 535 insertions(+), 75 deletions(-) create mode 100644 internal/taskdoor/engine_fixture_test.go create mode 100644 internal/taskdoor/performance_r8_test.go create mode 100644 internal/taskdoor/relative_copy_r8_test.go create mode 100644 internal/taskplace/engine_fixture_test.go create mode 100644 internal/taskplace/results_r8_test.go diff --git a/internal/taskdoor/copy.go b/internal/taskdoor/copy.go index 46d55eeeb4..e92764727e 100644 --- a/internal/taskdoor/copy.go +++ b/internal/taskdoor/copy.go @@ -38,6 +38,9 @@ type CopyDeps struct { Engine cellstore.Engine Store blobstore.Store Measure func(CopyMeasure) + // BeforeInstall observes the prepared result before filesystem replacement. + // Tests use this boundary to reproduce an editor racing installation. + BeforeInstall func() } type CopyMeasure struct { @@ -196,99 +199,168 @@ func ReceiveCopy(ctx context.Context, d CopyDeps, in Input, dest string) error { } func receiveCopy(ctx context.Context, d CopyDeps, in Head, dest, fingerprint string) error { - path, err := allowedCopy(dest) + prepared, err := prepareCopy(ctx, d, in, dest, fingerprint) if err != nil { return err } + defer prepared.Close() + return prepared.Install(ctx) +} + +// PreparedResult owns fetched files and their cell lock until installation or discard. +// Preparing never replaces the origin copy, so an abandonment can still fence installation. +type PreparedResult struct { + deps CopyDeps + path, stage, prepared, previous, fingerprint string + measure CopyMeasure + restoreStarted time.Time + unlock func() + keep bool +} + +func prepareCopy(ctx context.Context, d CopyDeps, in Head, dest, fingerprint string) (_ *PreparedResult, err error) { + path, err := allowedCopy(dest) + if err != nil { + return nil, err + } c, err := d.cell(in.Cell) if err != nil { - return err + return nil, err } if raw, err := hex.DecodeString(in.Head); err != nil || len(raw) != sha256.Size { - return errors.New("the task copy's head is not readable") + return nil, errors.New("the task copy's head is not readable") } unlock, err := d.lock(ctx, c.ID) if err != nil { - return err + return nil, err } - defer unlock() + result := &PreparedResult{deps: d, path: path, fingerprint: fingerprint, unlock: unlock} + defer func() { + if err != nil { + result.Close() + } + }() if fingerprint != "" { if err := unchangedCopy(ctx, path, fingerprint); err != nil { - return err + return nil, err } } if err := os.MkdirAll(c.Root, 0700); err != nil { - return err + return nil, err } start := time.Now() syncer := d.sync() fetcher := cellsync.Fetcher{Engine: syncer, Store: d.Store, Inbox: syncer.Inbox} if err := fetcher.Complete(ctx, c, in.Head); err != nil { - return err + return nil, err } - m := CopyMeasure{Fetch: time.Since(start)} - start = time.Now() + result.measure.Fetch = time.Since(start) + result.restoreStarted = time.Now() if err := syncer.Materialize(ctx, c, in.Head); err != nil { - return err + return nil, err } if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { - return err + return nil, err } - stage, err := os.MkdirTemp(filepath.Dir(path), ".task-arrival-") + result.stage, err = os.MkdirTemp(filepath.Dir(path), ".task-arrival-") if err != nil { - return err + return nil, err } - cleanStage := true - defer func() { - if cleanStage { - _ = os.RemoveAll(stage) - } - }() - prepared := filepath.Join(stage, "copy") - if err := taskcopy.RestoreCopy(filepath.Join(c.Root, copyCarrier), prepared); err != nil { - return err + result.prepared = filepath.Join(result.stage, "copy") + if err := taskcopy.RestoreCopy(filepath.Join(c.Root, copyCarrier), result.prepared); err != nil { + return nil, err } if err := ctx.Err(); err != nil { - return err + return nil, err } if fingerprint != "" { - if err := taskcopy.ImportCopyHistory(path, prepared); err != nil { - return err + if err := taskcopy.ImportCopyHistory(path, result.prepared); err != nil { + return nil, err } } - if fingerprint != "" { - // Recheck after the slow fetch and restore, immediately before the swap. - if err := unchangedCopy(ctx, path, fingerprint); err != nil { + return result, nil +} + +// Install holds no network work; the caller can guard this final boundary with its abandonment lock. +func (r *PreparedResult) Install(ctx context.Context) error { + if err := ctx.Err(); err != nil { + return err + } + if r.fingerprint != "" { + if err := unchangedCopy(ctx, r.path, r.fingerprint); err != nil { return err } - } else if entries, err := os.ReadDir(path); err == nil && len(entries) > 0 { + } else if entries, err := os.ReadDir(r.path); err == nil && len(entries) > 0 { return errors.New("the task's destination already contains files") } else if err != nil && !errors.Is(err, os.ErrNotExist) { return err } - backup := filepath.Join(stage, "previous") + if r.deps.BeforeInstall != nil { + r.deps.BeforeInstall() + } + // A sibling keeps relative Git worktree pointers based at the same parent. + previous, err := os.MkdirTemp(filepath.Dir(r.path), ".task-previous-") + if err != nil { + return err + } + // Some filesystems reject renaming over even the empty reservation. + if err := os.Remove(previous); err != nil { + return err + } + moved := false + if err := os.Rename(r.path, previous); err == nil { + moved = true + r.previous = previous + } else if !errors.Is(err, os.ErrNotExist) { + return err + } + // The moved original is now the installation boundary's authoritative snapshot. + // Check it before binding the returned branch or deleting any earlier files. undo := func() error { return nil } - if fingerprint != "" { - undo, err = taskcopy.BindLinkedResult(path, prepared) + if r.fingerprint != "" { + if err := unchangedCopy(ctx, previous, r.fingerprint); err != nil { + return r.rollback(previous, moved, err) + } + var err error + undo, err = taskcopy.BindLinkedResult(previous, r.prepared) if err != nil { - return err + return r.rollback(previous, moved, err) } } - if err := os.Rename(path, backup); err != nil && !errors.Is(err, os.ErrNotExist) { - return errors.Join(err, undo()) + if err := os.Rename(r.prepared, r.path); err != nil { + return r.rollback(previous, moved, errors.Join(err, undo())) + } + r.measure.Restore = time.Since(r.restoreStarted) + r.deps.measure(r.measure) + return nil +} + +func (r *PreparedResult) rollback(previous string, moved bool, cause error) error { + if !moved { + return cause } - if err := os.Rename(prepared, path); err != nil { - if restoreErr := os.Rename(backup, path); restoreErr != nil { - // A failed rollback must keep the original copy, even when its - // parent has become unwritable while the result was arriving. - cleanStage = false - return fmt.Errorf("could not put the result in place; the earlier copy is kept at %s: %w", backup, errors.Join(err, restoreErr)) + if err := os.Rename(previous, r.path); err != nil { + r.keep = true + return fmt.Errorf("could not put the result in place; the earlier copy is kept at %s: %w", previous, errors.Join(cause, err)) + } + r.previous = "" + return cause +} + +// Close discards an uninstalled result, but preserves a previous copy whose rollback failed. +func (r *PreparedResult) Close() { + if !r.keep { + if r.stage != "" { + _ = os.RemoveAll(r.stage) + } + if r.previous != "" { + _ = os.RemoveAll(r.previous) } - return errors.Join(err, undo()) } - m.Restore = time.Since(start) - d.measure(m) - return nil + if r.unlock != nil { + r.unlock() + r.unlock = nil + } } func SendResult(ctx context.Context, d CopyDeps, workDir string, in Input) (Result, error) { @@ -306,10 +378,20 @@ func SendResult(ctx context.Context, d CopyDeps, workDir string, in Input) (Resu var ErrCopyChanged = errors.New("the task copy changed here while the other computer worked; both copies were kept") func ReceiveResult(ctx context.Context, d CopyDeps, result Result, localCopyDir, sentFingerprint string) error { + prepared, err := PrepareResult(ctx, d, result, localCopyDir, sentFingerprint) + if err != nil { + return err + } + defer prepared.Close() + return prepared.Install(ctx) +} + +// PrepareResult fetches and materializes outside the session's abandonment mutex. +func PrepareResult(ctx context.Context, d CopyDeps, result Result, localCopyDir, sentFingerprint string) (*PreparedResult, error) { if sentFingerprint == "" { - return errors.New("the sent task copy's fingerprint is missing") + return nil, errors.New("the sent task copy's fingerprint is missing") } - return receiveCopy(ctx, d, result, localCopyDir, sentFingerprint) + return prepareCopy(ctx, d, result, localCopyDir, sentFingerprint) } func unchangedCopy(ctx context.Context, path, fingerprint string) error { now, err := copyFingerprint(ctx, path) diff --git a/internal/taskdoor/copy_test.go b/internal/taskdoor/copy_test.go index ccd92a015c..e1c908ebde 100644 --- a/internal/taskdoor/copy_test.go +++ b/internal/taskdoor/copy_test.go @@ -17,17 +17,13 @@ import ( "github.com/Agent-Field/codeaf/internal/blobstore" "github.com/Agent-Field/codeaf/internal/cellstore" - "github.com/Agent-Field/codeaf/internal/furrow" "github.com/Agent-Field/codeaf/internal/taskdoor" ) func copyHomes(t *testing.T) (taskdoor.CopyDeps, taskdoor.CopyDeps) { t.Helper() r := newRig(t) - bin, err := furrow.ResolveOwned() - if err != nil { - t.Fatal(err) - } + bin := taskEngineBinary(t) mem := blobstore.NewMemory() srv := httptest.NewServer(blobstore.Handler(func(*http.Request, []byte) (string, string, error) { return r.id.ID(), r.a.ID(), nil @@ -271,8 +267,7 @@ func TestASecondSendUploadsOnlyTheChange(t *testing.T) { t.Skip("10,000-file measurement") } started := time.Now() - ctx, cancel := context.WithTimeout(t.Context(), time.Minute) - defer cancel() + ctx := t.Context() a, b := copyHomes(t) local := copyRepo(t) for n := 0; n < 10000; n++ { @@ -299,7 +294,40 @@ func TestASecondSendUploadsOnlyTheChange(t *testing.T) { t.Fatalf("second upload = %d bytes", upload.Bytes) } } - if elapsed := time.Since(started); elapsed >= time.Minute { - t.Fatalf("delta measurement exceeded one minute: %s", elapsed) + t.Logf("delta-measure: total=%s", time.Since(started)) +} + +// R7-5 and contract 3 keep an edit arriving after the last optimistic check. +func TestAnEditMadeWhileTheResultInstallsIsKept(t *testing.T) { + a, b := copyHomes(t) + ground := copyRepo(t) + local := filepath.Join(t.TempDir(), "task") + copyGit(t, ground, "worktree", "add", "-q", "-b", "task-r8", local, "HEAD") + head, index := copyGit(t, local, "rev-parse", "HEAD"), copyGit(t, local, "diff", "--cached", "--binary") + in, err := taskdoor.SendCopy(t.Context(), a, local) + if err != nil { + t.Fatal(err) + } + work := filepath.Join(t.TempDir(), "work") + if err = taskdoor.ReceiveCopy(t.Context(), b, in, work); err != nil { + t.Fatal(err) + } + copyFile(t, work, "edit.txt", "remote edit") + copyGit(t, work, "add", "--all") + copyGit(t, work, "commit", "-qm", "remote edit") + result, err := taskdoor.SendResult(t.Context(), b, work, in) + if err != nil { + t.Fatal(err) + } + a.BeforeInstall = func() { copyFile(t, local, "edit.txt", "edit made during installation") } + if err = taskdoor.ReceiveResult(t.Context(), a, result, local, in.Fingerprint); !errors.Is(err, taskdoor.ErrCopyChanged) { + t.Fatalf("racing edit accepted: %v", err) + } + raw, err := os.ReadFile(filepath.Join(local, "edit.txt")) + if err != nil || string(raw) != "edit made during installation" { + t.Fatalf("edit lost: %q, %v", raw, err) + } + if copyGit(t, local, "rev-parse", "HEAD") != head || copyGit(t, local, "diff", "--cached", "--binary") != index { + t.Fatal("refusal changed the branch or index") } } diff --git a/internal/taskdoor/engine_fixture_test.go b/internal/taskdoor/engine_fixture_test.go new file mode 100644 index 0000000000..24529e841d --- /dev/null +++ b/internal/taskdoor/engine_fixture_test.go @@ -0,0 +1,30 @@ +//go:build linux || darwin + +package taskdoor_test + +import ( + "errors" + "testing" + + "github.com/Agent-Field/codeaf/internal/furrowbin" +) + +// taskEngineBinary keeps a source-only CI checkout useful without replacing the real engine. +func taskEngineBinary(t *testing.T) string { + t.Helper() + binary, configured, err := furrowbin.ConfiguredBinary() + if err != nil { + t.Fatal(err) + } + if configured { + return binary + } + binary, err = furrowbin.Ensure() + if errors.Is(err, furrowbin.ErrNotEmbedded) { + t.Skip("task-copy proof needs a real engine: run make build first, or set " + furrowbin.BinaryEnvVar) + } + if err != nil { + t.Fatal(err) + } + return binary +} diff --git a/internal/taskdoor/executor.go b/internal/taskdoor/executor.go index 53da8c65aa..227ffbae01 100644 --- a/internal/taskdoor/executor.go +++ b/internal/taskdoor/executor.go @@ -268,6 +268,7 @@ func (e *ChildExecutor) follow(ctx context.Context, a *Attempt, j Journal, done } beat := e.clock.NewTicker(childPoll) defer beat.Stop() + var observedExit *int for { if err := ctx.Err(); err != nil { return err @@ -275,16 +276,16 @@ func (e *ChildExecutor) follow(ctx context.Context, a *Attempt, j Journal, done if err := follower.poll(j); err != nil { return err } - var code *int - ended := false - if done != nil { + code := observedExit + ended := code != nil + if done != nil && !ended { select { case exit := <-done: code = &exit ended = true default: } - } else { + } else if done == nil { ended = !e.alive(a) code = a.State().ExitCode } @@ -307,6 +308,9 @@ func (e *ChildExecutor) follow(ctx context.Context, a *Attempt, j Journal, done case <-ctx.Done(): return ctx.Err() case <-beat.C(): + case exit := <-done: + // A reaped child must report its receipt without waiting for another poll tick. + observedExit = &exit } } } diff --git a/internal/taskdoor/performance_r8_test.go b/internal/taskdoor/performance_r8_test.go new file mode 100644 index 0000000000..8a584d79f4 --- /dev/null +++ b/internal/taskdoor/performance_r8_test.go @@ -0,0 +1,49 @@ +package taskdoor_test + +import ( + "go/ast" + "go/parser" + "go/token" + "testing" +) + +// R7-17 enforces PERF.md at the measurement's source boundary: time is diagnostic. +func TestTheTaskCopyDeltaMeasurementNeverGatesOnWallClockSpeed(t *testing.T) { + file, err := parser.ParseFile(token.NewFileSet(), "copy_test.go", nil, 0) + if err != nil { + t.Fatal(err) + } + ast.Inspect(file, func(node ast.Node) bool { + branch, ok := node.(*ast.IfStmt) + if !ok { + return true + } + clock, fatal := false, false + inspectClock := func(node ast.Node) bool { + if call, ok := node.(*ast.CallExpr); ok { + if method, ok := call.Fun.(*ast.SelectorExpr); ok { + if owner, ok := method.X.(*ast.Ident); ok && owner.Name == "time" && method.Sel.Name == "Since" { + clock = true + } + } + } + return true + } + if branch.Init != nil { + ast.Inspect(branch.Init, inspectClock) + } + ast.Inspect(branch.Cond, inspectClock) + ast.Inspect(branch.Body, func(node ast.Node) bool { + if call, ok := node.(*ast.CallExpr); ok { + if method, ok := call.Fun.(*ast.SelectorExpr); ok && (method.Sel.Name == "Fatal" || method.Sel.Name == "Fatalf" || method.Sel.Name == "Error" || method.Sel.Name == "Errorf") { + fatal = true + } + } + return true + }) + if clock && fatal { + t.Error("task-copy measurement fails on elapsed wall-clock time") + } + return true + }) +} diff --git a/internal/taskdoor/relative_copy_r8_test.go b/internal/taskdoor/relative_copy_r8_test.go new file mode 100644 index 0000000000..a6fca7fc32 --- /dev/null +++ b/internal/taskdoor/relative_copy_r8_test.go @@ -0,0 +1,63 @@ +//go:build linux || darwin + +package taskdoor_test + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/taskdoor" +) + +// R7-5 preserves the linked-copy landing contract when Git uses relative worktree links. +func TestARelativeWorktreeLinkSurvivesResultInstallation(t *testing.T) { + a, b := copyHomes(t) + ground := copyRepo(t) + local := filepath.Join(t.TempDir(), "task") + copyGit(t, ground, "-c", "worktree.useRelativePaths=true", "worktree", "add", "-q", "-b", "task-relative", local, "HEAD") + raw, err := os.ReadFile(filepath.Join(local, ".git")) + if err != nil { + t.Fatal(err) + } + target := strings.TrimSpace(strings.TrimPrefix(string(raw), "gitdir: ")) + if filepath.IsAbs(target) { + relative, err := filepath.Rel(local, target) + if err != nil { + t.Fatal(err) + } + raw = []byte("gitdir: " + relative + "\n") + if err := os.WriteFile(filepath.Join(local, ".git"), raw, 0644); err != nil { + t.Fatal(err) + } + } + in, err := taskdoor.SendCopy(t.Context(), a, local) + if err != nil { + t.Fatal(err) + } + work := filepath.Join(t.TempDir(), "work") + if err := taskdoor.ReceiveCopy(t.Context(), b, in, work); err != nil { + t.Fatal(err) + } + copyFile(t, work, "edit.txt", "returned through a relative worktree") + copyGit(t, work, "add", "--all") + copyGit(t, work, "commit", "-qm", "relative return") + result, err := taskdoor.SendResult(t.Context(), b, work, in) + if err != nil { + t.Fatal(err) + } + if err := taskdoor.ReceiveResult(t.Context(), a, result, local, in.Fingerprint); err != nil { + t.Fatalf("relative worktree result refused: %v", err) + } + if got := copyGit(t, local, "rev-parse", "HEAD"); got != copyGit(t, work, "rev-parse", "HEAD") { + t.Fatal("returned commit was not installed") + } + after, err := os.ReadFile(filepath.Join(local, ".git")) + if err != nil || string(after) != string(raw) { + t.Fatalf("relative worktree link changed: %q, %v", after, err) + } + if got := copyGit(t, local, "branch", "--show-current"); got != "task-relative" { + t.Fatalf("landing branch = %q", got) + } +} diff --git a/internal/taskplace/engine.go b/internal/taskplace/engine.go index 1eb2232fbb..925c20031b 100644 --- a/internal/taskplace/engine.go +++ b/internal/taskplace/engine.go @@ -271,15 +271,16 @@ func (e *Engine) follow(ctx context.Context, spec session.RunSpec, record sessio if final.Result == nil && final.Outcome != "interrupted" { return session.RunSummary{Outcome: "incomplete", Result: final.Report + "\nThe result could not be brought from " + e.Computer.Name + "; the copy here was kept", USD: record.USD, KeepCopy: true, NeedsDecision: true} } - if final.Result != nil && !record.Restored { - apply := func() error { - return taskdoor.ReceiveResult(ctx, e.Copies, *final.Result, spec.Workspace, record.Fingerprint) - } - var err error - if spec.WithRemoteCopy != nil { - err = spec.WithRemoteCopy(apply) - } else { - err = apply() + if final.Result != nil && !record.Restored && final.Outcome != "interrupted" { + prepared, err := taskdoor.PrepareResult(ctx, e.Copies, *final.Result, spec.Workspace, record.Fingerprint) + if err == nil { + defer prepared.Close() + apply := func() error { return prepared.Install(ctx) } + if spec.WithRemoteCopy != nil { + err = spec.WithRemoteCopy(apply) + } else { + err = apply() + } } if err != nil { summary := failed(err) @@ -296,6 +297,9 @@ func (e *Engine) follow(ctx context.Context, spec session.RunSpec, record sessio } outcome := final.Outcome + if outcome == "interrupted" && final.Result != nil { + final.Report += "\nThe partial work stayed on the other computer; run it here starts from the sent copy." + } switch outcome { case "done": _ = spec.Store.CompleteRoot(final.Report) diff --git a/internal/taskplace/engine_fixture_test.go b/internal/taskplace/engine_fixture_test.go new file mode 100644 index 0000000000..d7d40a909a --- /dev/null +++ b/internal/taskplace/engine_fixture_test.go @@ -0,0 +1,30 @@ +//go:build linux || darwin + +package taskplace_test + +import ( + "errors" + "testing" + + "github.com/Agent-Field/codeaf/internal/furrowbin" +) + +// taskEngineBinary keeps a source-only CI checkout useful without replacing the real engine. +func taskEngineBinary(t *testing.T) string { + t.Helper() + binary, configured, err := furrowbin.ConfiguredBinary() + if err != nil { + t.Fatal(err) + } + if configured { + return binary + } + binary, err = furrowbin.Ensure() + if errors.Is(err, furrowbin.ErrNotEmbedded) { + t.Skip("task-copy proof needs a real engine: run make build first, or set " + furrowbin.BinaryEnvVar) + } + if err != nil { + t.Fatal(err) + } + return binary +} diff --git a/internal/taskplace/engine_test.go b/internal/taskplace/engine_test.go index 4cb7f60ee5..755d447592 100644 --- a/internal/taskplace/engine_test.go +++ b/internal/taskplace/engine_test.go @@ -18,7 +18,6 @@ import ( "github.com/Agent-Field/codeaf/internal/blobstore" "github.com/Agent-Field/codeaf/internal/cellstore" "github.com/Agent-Field/codeaf/internal/devname" - "github.com/Agent-Field/codeaf/internal/furrow" "github.com/Agent-Field/codeaf/internal/identity" "github.com/Agent-Field/codeaf/internal/plandb" "github.com/Agent-Field/codeaf/internal/relay" @@ -142,10 +141,7 @@ func TestATaskSentToTheOtherHomeRunsThereAndItsChangeComesBack(t *testing.T) { memory := blobstore.NewMemory() blobs := httptest.NewServer(blobstore.Handler(func(*http.Request, []byte) (string, string, error) { return id.ID(), a.ID(), nil }, func(string) (blobstore.Store, error) { return memory, nil })) defer blobs.Close() - bin, err := furrow.ResolveOwned() - if err != nil { - t.Fatal(err) - } + bin := taskEngineBinary(t) makeCopy := func(home string, device identity.Dev) taskdoor.CopyDeps { socketHome, err := os.MkdirTemp("", "rn-l1-") if err != nil { diff --git a/internal/taskplace/results_r8_test.go b/internal/taskplace/results_r8_test.go new file mode 100644 index 0000000000..e4b3851cf6 --- /dev/null +++ b/internal/taskplace/results_r8_test.go @@ -0,0 +1,174 @@ +//go:build linux || darwin + +package taskplace_test + +import ( + "context" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/blobstore" + "github.com/Agent-Field/codeaf/internal/cell" + "github.com/Agent-Field/codeaf/internal/cellstore" + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/plandb" + "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/taskdoor" + "github.com/Agent-Field/codeaf/internal/taskplace" +) + +func resultCopyFixture(t *testing.T) (taskdoor.CopyDeps, string, *plandb.Store, session.TaskRemoteRecord) { + t.Helper() + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("CODEAF_HOME", home) + id, err := identity.Ensure(home) + if err != nil { + t.Fatal(err) + } + device, err := identity.LoadDevice(home) + if err != nil { + t.Fatal(err) + } + bin := taskEngineBinary(t) + socketHome, err := os.MkdirTemp("", "rn-l1-r8-") + if err != nil { + t.Fatal(err) + } + daemon := cellstore.Daemon{Binary: bin, Socket: filepath.Join(socketHome, "e.sock")} + t.Cleanup(func() { _ = daemon.Stop(context.Background()); _ = os.RemoveAll(socketHome) }) + engine := cellstore.EngineFor("") + engine.Binary = bin + engine.Transport = daemon + engine.DataRoot = filepath.Join(home, "stores") + engine.Guard.Home = home + copies := taskdoor.CopyDeps{Root: filepath.Join(home, "copies"), Service: "http://127.0.0.1:8900", Identity: id, Device: device, Engine: engine, Store: blobstore.NewMemory()} + work := t.TempDir() + git := func(args ...string) { + t.Helper() + cmd := exec.Command("git", append([]string{"-C", work, "-c", "user.name=test", "-c", "user.email=test@example.test"}, args...)...) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git: %v: %s", err, out) + } + } + git("init", "-qb", "task-copy") + if err = os.WriteFile(filepath.Join(work, "edit.txt"), []byte("sent baseline"), 0600); err != nil { + t.Fatal(err) + } + git("add", "--all") + git("commit", "-qm", "baseline") + fingerprint, err := taskdoor.CopyFingerprint(t.Context(), work) + if err != nil { + t.Fatal(err) + } + store, err := plandb.Open(filepath.Join(t.TempDir(), "plandb.db"), "task", "1", "repair", "brief") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = store.Close() }) + return copies, work, store, session.TaskRemoteRecord{Executor: session.TaskExecutor{Device: "other", Name: "work computer"}, Attempt: "att_0123456789abcdef", Fingerprint: fingerprint, Finished: true} +} + +type delayedResultStore struct { + blobstore.Store + entered, release chan struct{} +} + +func (s *delayedResultStore) GetMany(ctx context.Context, _ []string) ([]blobstore.Object, error) { + close(s.entered) + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-s.release: + return nil, blobstore.ErrUnreachable + } +} + +// R7-14 and contract 11 allow stop to acquire the session's copy fence during a real fetch. +func TestAStopIsNotBlockedByAResultStillDownloading(t *testing.T) { + copies, work, store, record := resultCopyFixture(t) + blobs := &delayedResultStore{Store: copies.Store, entered: make(chan struct{}), release: make(chan struct{})} + copies.Store = blobs + cellID, err := cell.NewID(time.Now()) + if err != nil { + t.Fatal(err) + } + record.Final, _ = json.Marshal(taskdoor.Finished{Outcome: "done", Result: &taskdoor.Head{Cell: cellID, Head: strings.Repeat("0", 64)}}) + remote := &taskplace.Engine{Copies: copies, Computer: session.TaskComputer{Name: "work computer"}} + var copyFence sync.Mutex + done := make(chan session.RunSummary, 1) + go func() { + done <- remote.Resume(t.Context(), session.RunSpec{Workspace: work, Store: store, Remote: &record, SaveRemote: func(session.TaskRemoteRecord, bool) error { return nil }, WithRemoteCopy: func(install func() error) error { copyFence.Lock(); defer copyFence.Unlock(); return install() }}) + }() + defer func() { close(blobs.release); <-done }() + select { + case <-blobs.entered: + case summary := <-done: + done <- summary + t.Fatalf("fetch ended before the gate: %+v", summary) + case <-time.After(5 * time.Second): + t.Fatal("result never reached its download") + } + stopped := make(chan struct{}) + go func() { + copyFence.Lock() + defer copyFence.Unlock() + _ = remote.Stop(t.Context(), session.TaskRemoteRecord{}) + close(stopped) + }() + select { + case <-stopped: + case <-time.After(time.Second): + t.Fatal("stop waited for the result download") + } +} + +// R7-9 and contract 11 preserve the actual sent fingerprint for local fallback. +func TestRunItHereWorksAfterAnInterruptedRemoteRun(t *testing.T) { + copies, work, store, record := resultCopyFixture(t) + other, _, _, _ := resultCopyFixture(t) + other.Identity = copies.Identity + var err error + other.Device, err = identity.NewDevice(copies.Identity) + if err != nil { + t.Fatal(err) + } + other.Store = copies.Store + input, err := taskdoor.SendCopy(t.Context(), copies, work) + if err != nil { + t.Fatal(err) + } + partial := filepath.Join(t.TempDir(), "partial") + if err := taskdoor.ReceiveCopy(t.Context(), other, input, partial); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(partial, "edit.txt"), []byte("unfinished remote edit"), 0600); err != nil { + t.Fatal(err) + } + result, err := taskdoor.SendResult(t.Context(), other, partial, input) + if err != nil { + t.Fatal(err) + } + record.Final, _ = json.Marshal(taskdoor.Finished{Outcome: "interrupted", Report: "the worker stopped", Result: &result}) + remote := &taskplace.Engine{Copies: copies, Computer: session.TaskComputer{Name: "work computer"}} + summary := remote.Resume(t.Context(), session.RunSpec{Workspace: work, Store: store, Remote: &record, SaveRemote: func(r session.TaskRemoteRecord, _ bool) error { record = r; return nil }}) + if summary.Outcome != "interrupted" || !strings.Contains(summary.Result, "partial work stayed on the other computer") { + t.Fatalf("interrupted result = %+v", summary) + } + if err := remote.CheckCopy(t.Context(), work, record.Fingerprint); err != nil { + t.Fatalf("run it here refused its baseline: %v", err) + } + raw, err := os.ReadFile(filepath.Join(work, "edit.txt")) + if err != nil || string(raw) != "sent baseline" { + t.Fatalf("baseline changed: %q, %v", raw, err) + } + if record.Restored { + t.Fatal("partial work marked restored") + } +} From 32ea83fddb04907cdbf8e7095187beb648add027 Mon Sep 17 00:00:00 2001 From: Abir Abbas <abirabbas1998@gmail.com> Date: Sat, 31 Oct 2026 01:14:46 -0400 Subject: [PATCH 25/34] fix: keep mirrored record kinds, find an owed acknowledgement on reopen, and tell same-named computers apart Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/devname/devname.go | 6 + internal/session/task_accounting_r8_test.go | 42 ++++ internal/session/task_elsewhere.go | 158 ++++++++++--- internal/session/task_names_r8_test.go | 45 ++++ internal/session/task_records_r8_test.go | 176 +++++++++++++++ internal/session/task_remote_control.go | 232 ++++++++++++-------- internal/session/task_remote_money.go | 56 +++-- internal/session/task_remote_record.go | 82 ++++--- internal/taskplace/computers.go | 6 + internal/taskplace/computers_r8_test.go | 46 ++++ internal/tui3/devices.go | 4 +- 11 files changed, 681 insertions(+), 172 deletions(-) create mode 100644 internal/session/task_accounting_r8_test.go create mode 100644 internal/session/task_names_r8_test.go create mode 100644 internal/session/task_records_r8_test.go create mode 100644 internal/taskplace/computers_r8_test.go diff --git a/internal/devname/devname.go b/internal/devname/devname.go index 5c8369e8c0..035ae54fa6 100644 --- a/internal/devname/devname.go +++ b/internal/devname/devname.go @@ -122,3 +122,9 @@ func Set(dir, typed string) (string, error) { } func path(dir string) string { return filepath.Join(dir, fileName) } + +// DistinctName is the devices page's label when computers share a shown name. +func DistinctName(name, id string) string { + const tail = 4 + return name + " #" + id[max(len(id)-tail, 0):] +} diff --git a/internal/session/task_accounting_r8_test.go b/internal/session/task_accounting_r8_test.go new file mode 100644 index 0000000000..30cec4c961 --- /dev/null +++ b/internal/session/task_accounting_r8_test.go @@ -0,0 +1,42 @@ +package session + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +// R7-8 and contract 5 include a complete ledger object whose final newline was lost. +func TestARemoteChargeWithoutItsNewlineIsNotChargedTwice(t *testing.T) { + path := filepath.Join(t.TempDir(), "usage.jsonl") + line := UsageLine{RemoteEvent: "att_0123456789abcdef:3", USD: .25} + raw, err := json.Marshal(line) + if err != nil { + t.Fatal(err) + } + if err = os.WriteFile(path, raw, 0600); err != nil { + t.Fatal(err) + } + if err = appendRemoteUsage(path, line); err != nil { + t.Fatal(err) + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(got) != string(raw) { + t.Fatalf("replaying a complete EOF charge appended bytes: %s", got) + } + if strings.Count(string(got), line.RemoteEvent) != 1 { + t.Fatalf("charge repeated: %s", got) + } + if err = appendRemoteUsage(path, line); err != nil { + t.Fatal(err) + } + again, _ := os.ReadFile(path) + if string(got) != string(again) { + t.Fatal("second replay changed the ledger") + } +} diff --git a/internal/session/task_elsewhere.go b/internal/session/task_elsewhere.go index c98f636726..fcbe71befa 100644 --- a/internal/session/task_elsewhere.go +++ b/internal/session/task_elsewhere.go @@ -11,6 +11,7 @@ import ( "github.com/Agent-Field/codeaf/internal/config" "github.com/Agent-Field/codeaf/internal/crewroute" + "github.com/Agent-Field/codeaf/internal/devname" ) // TaskComputerControlWithin bounds best-effort stop and acknowledgement calls. @@ -21,6 +22,7 @@ const TaskComputerControlWithin = 10 * time.Second type TaskComputer struct { Device string Name string + OriginalName string `json:"originalName,omitempty"` CanTakeTasks bool Reason string } @@ -111,7 +113,7 @@ func (a *Agent) resolveTaskComputer(ctx context.Context, named string, stand tas return p, nil } explicit := named != "" - if explicit && (strings.EqualFold(named, "here") || strings.EqualFold(named, config.TaskRunsHere) || strings.EqualFold(named, a.config.TaskComputers.ThisComputer())) { + if explicit && (strings.EqualFold(named, "here") || strings.EqualFold(named, config.TaskRunsHere)) { return p, nil } preference := config.TaskRunsOnAt(a.config.ProfileDir) @@ -122,45 +124,111 @@ func (a *Agent) resolveTaskComputer(ctx context.Context, named string, stand tas if err != nil { return p, err } - for _, computer := range computers { - if explicit && strings.EqualFold(strings.TrimSpace(computer.Name), named) || !explicit && preference != "" && computer.Device == preference { - if !computer.CanTakeTasks { - if explicit { - p.sentence = computer.Reason - } - return p, nil - } - reason := "" - switch { - case kind == TaskKindQuick: - reason = "a quick task" - case program: - reason = "a program run" - case !taskCopyCanTravel(stand): - reason = "this task" - } - if reason != "" { - if explicit { - p.sentence = reason + " runs here — only a task that works in its own copy can run on " + computer.Name - } - return p, nil + localName := a.config.TaskComputers.ThisComputer() + computer, err := chooseTaskComputer(TaskComputerNames(computers, localName), named, preference, localName) + if err != nil || computer == nil { + return p, err + } + return eligibleTaskComputer(p, *computer, stand, kind, program, explicit), nil +} + +// TaskComputerNames keeps device labels consistent while retaining ambiguous bare names for refusal. +func TaskComputerNames(rows []TaskComputer, localName string) []TaskComputer { + rows = append([]TaskComputer(nil), rows...) + counts := map[string]int{strings.ToLower(strings.TrimSpace(localName)): 1} + for _, row := range rows { + counts[taskComputerBareName(row)]++ + } + for i := range rows { + row := &rows[i] + if counts[taskComputerBareName(*row)] > 1 { + if row.OriginalName == "" { + row.OriginalName = row.Name } - p.computer = &computer - return p, nil + row.Name = devname.DistinctName(row.OriginalName, row.Device) + } + } + return rows +} +func taskComputerBareName(row TaskComputer) string { + name := row.OriginalName + if name == "" { + name = row.Name + } + return strings.ToLower(strings.TrimSpace(name)) +} + +// chooseTaskComputer never gives roster order authority over an ambiguous name. +func chooseTaskComputer(computers []TaskComputer, named, preference, localName string) (*TaskComputer, error) { + explicit := named != "" + local := explicit && strings.EqualFold(named, localName) + var matches []TaskComputer + for _, computer := range computers { + if taskComputerMatches(computer, named, preference) { + matches = append(matches, computer) + } + } + if len(matches) > 1 || local && len(matches) > 0 { + choices := taskComputerChoices(matches) + if local { + choices = append(choices, config.TaskRunsHere) } + return nil, fmt.Errorf("%q matches more than one paired computer — choose one of: %s", named, strings.Join(choices, ", ")) + } + if local { + return nil, nil + } + if len(matches) == 1 { + return &matches[0], nil } if explicit { - names := make([]string, 0, len(computers)) - for _, c := range computers { - names = append(names, c.Name) + return nil, fmt.Errorf("no paired computer is named %q — your computers are: %s", named, strings.Join(taskComputerChoices(computers), ", ")) + } + return nil, nil +} +func taskComputerMatches(computer TaskComputer, named, preference string) bool { + if named == "" { + return preference != "" && computer.Device == preference + } + return strings.EqualFold(named, computer.Name) || strings.EqualFold(named, taskComputerBareName(computer)) +} +func taskComputerChoices(computers []TaskComputer) []string { + names := make([]string, 0, len(computers)) + for _, computer := range computers { + names = append(names, computer.Name) + } + sort.Strings(names) + if len(names) == 0 { + names = append(names, config.TaskRunsHere) + } + return names +} + +// Ineligible work retains the existing local road and its explicit-choice explanation. +func eligibleTaskComputer(p taskPlacement, computer TaskComputer, stand taskStand, kind TaskKind, program, explicit bool) taskPlacement { + if !computer.CanTakeTasks { + if explicit { + p.sentence = computer.Reason } - sort.Strings(names) - if len(names) == 0 { - names = append(names, config.TaskRunsHere) + return p + } + reason := "" + switch { + case kind == TaskKindQuick: + reason = "a quick task" + case program: + reason = "a program run" + case !taskCopyCanTravel(stand): + reason = "this task" + } + if reason != "" { + if explicit { + p.sentence = reason + " runs here — only a task that works in its own copy can run on " + computer.Name } - return p, fmt.Errorf("no paired computer is named %q — your computers are: %s", named, strings.Join(names, ", ")) + return p } - return p, nil + p.computer = &computer + return p } // StartTaskOn is the shared session entry for a person's explicit placement. @@ -225,3 +293,27 @@ func taskCopyCanTravel(stand taskStand) bool { func (c Config) mayNameTaskComputer() bool { return c.TaskComputers != nil && config.TaskRunsOnAt(c.ProfileDir) != "" } + +// The selected engine is resolved before start admission without changing local placement. +func (a *Agent) placeTaskRunEngine(ctx context.Context, engine RunEngine, stand taskStand, program bool) (context.Context, RunEngine, error) { + placement := taskPlacementOf(ctx) + if _, resolved := ctx.Value(taskPlacementKey{}).(taskPlacement); !resolved { + var err error + placement, err = a.resolveTaskComputer(ctx, "", stand, "", program) + if err != nil { + return ctx, nil, err + } + ctx = withTaskPlacement(ctx, placement) + } + if placement.computer == nil { + return ctx, engine, nil + } + remote, err := a.config.TaskComputers.Engine(ctx, placement.computer.Device) + if err != nil { + return ctx, nil, err + } + if remote == nil { + return ctx, nil, errors.New("this computer cannot run that task elsewhere right now") + } + return ctx, remote, nil +} diff --git a/internal/session/task_names_r8_test.go b/internal/session/task_names_r8_test.go new file mode 100644 index 0000000000..4949452429 --- /dev/null +++ b/internal/session/task_names_r8_test.go @@ -0,0 +1,45 @@ +package session + +import ( + "context" + "encoding/json" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/config" +) + +type sameNameTaskComputers struct{ testTaskComputers } + +func (sameNameTaskComputers) List(context.Context) ([]TaskComputer, error) { + return []TaskComputer{ + {Device: "dev_0123456789abcdef0123456789ab1111", Name: "mac mini", CanTakeTasks: true}, + {Device: "dev_0123456789abcdef0123456789ab2222", Name: "mac mini", CanTakeTasks: true}, + }, nil +} + +// R7-12 and contract 6 require an ambiguous tool name to list distinct choices. +func TestTwoComputersWithOneNameAreToldApart(t *testing.T) { + taskComputerChoiceHome(t) + a, _ := newTestAgent(t, &scriptedCompleter{}, func(c *Config) { + c.ProfileDir = t.TempDir() + c.TaskComputers = sameNameTaskComputers{} + if err := config.WriteTaskRunsOn(c.ProfileDir, "dev_0123456789abcdef0123456789ab1111"); err != nil { + t.Fatal(err) + } + }) + args := taskOnOtherComputer() + args.Computer = "mac mini" + raw, _ := json.Marshal(args) + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + text, bad, err := a.stageTask(ctx, raw).Commit(ctx) + if err != nil || !bad || !strings.Contains(text, "more than one") || !strings.Contains(text, "mac mini #1111") || !strings.Contains(text, "mac mini #2222") { + t.Fatalf("ambiguous proposal: %q, %v, %v", text, bad, err) + } + p, err := a.resolveTaskComputer(context.Background(), "mac mini #2222", taskStand{mode: TaskModeWorktree}, "", false) + if err != nil || p.computer == nil || p.computer.Device != "dev_0123456789abcdef0123456789ab2222" { + t.Fatalf("second computer = %+v, %v", p.computer, err) + } +} diff --git a/internal/session/task_records_r8_test.go b/internal/session/task_records_r8_test.go new file mode 100644 index 0000000000..6e010028d9 --- /dev/null +++ b/internal/session/task_records_r8_test.go @@ -0,0 +1,176 @@ +package session + +import ( + "bytes" + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/plandb" +) + +// R7-11 and contract 2 preserve lifecycle records without inventing tool steps. +func TestMirroredBeginAndEndAreNotDrawnAsSteps(t *testing.T) { + taskComputerChoiceHome(t) + remote := &scriptedTaskEngine{start: func(ctx context.Context, spec RunSpec) RunSummary { + r := remoteRecordFor(spec) + if err := spec.SaveRemote(r, true); err != nil { + t.Error(err) + } + for i, raw := range []string{`{"kind":"begin","brief":"repair"}`, `{"kind":"step","step":1,"command":"inspect"}`, `{"kind":"notes","observation":"progress"}`, `{"kind":"end","outcome":"done"}`} { + if err := spec.OnRemoteStep(uint64(i+1), json.RawMessage(raw)); err != nil { + t.Error(err) + } + } + _ = spec.Store.CompleteRoot("done") + return RunSummary{Outcome: "done"} + }} + a, _ := remoteTaskAgent(t, remote, nil) + if _, _, _, err := a.StartTaskOn(context.Background(), "Repair export", false, "work computer"); err != nil { + t.Fatal(err) + } + waitRemoteRun(t, a) + page, ok := a.PlanTaskPage("t-1") + if !ok || len(page.Steps) != 1 || page.Steps[0].Command != "inspect" { + t.Fatalf("drawn steps = %+v", page.Steps) + } + raw, err := os.ReadFile(filepath.Join(plandb.TaskDir(filepath.Dir(a.graph().planPath()), "1"), "trajectory.jsonl")) + if err != nil { + t.Fatal(err) + } + for _, kind := range []string{"begin", "notes", "end"} { + var found bool + for _, line := range bytes.Split(raw, []byte{'\n'}) { + var record struct { + Kind string `json:"kind"` + } + _ = json.Unmarshal(line, &record) + found = found || record.Kind == kind + } + if !found { + t.Errorf("lost %s kind in %s", kind, raw) + } + } +} + +type ackOwedEngine struct { + scriptedTaskEngine + ack chan TaskRemoteRecord +} + +func (e *ackOwedEngine) Acknowledge(_ context.Context, r TaskRemoteRecord) error { + e.ack <- r + return nil +} + +// R7-13 and contract 10 recover the sidecar obligation even when the checkpoint predates it. +func TestAnAckOwedWhenTheConversationCrashedIsSentOnReopen(t *testing.T) { + taskComputerChoiceHome(t) + ready := make(chan struct{}) + remote := &ackOwedEngine{ack: make(chan TaskRemoteRecord, 2)} + remote.start = func(ctx context.Context, spec RunSpec) RunSummary { + r := remoteRecordFor(spec) + _ = spec.SaveRemote(r, true) + close(ready) + <-ctx.Done() + return RunSummary{Outcome: "interrupted", KeepCopy: true} + } + place, repo, profile := t.TempDir(), beltRunCommittedRepo(t), t.TempDir() + open := func() *Agent { + a, _ := newTestAgent(t, beltRunCompleter{text: "done"}, func(c *Config) { + c.Workspace = repo + c.Place = Place{Dir: place} + c.SessionFile = c.Place.Transcript() + c.ProfileDir = profile + c.TaskComputers = testTaskComputers{engine: remote} + }) + return a + } + local := newBeltRunDouble("done") + local.real = true + registerBeltRunEngine(t, local) + a := open() + if _, _, _, err := a.StartTaskOn(context.Background(), "Repair export", false, "work computer"); err != nil { + t.Fatal(err) + } + <-ready + _ = a.Close() + waitRemoteRun(t, a) + store, err := openRemoteTaskPlan(a.graph(), 1) + if err != nil { + t.Fatal(err) + } + row, _ := runRowOf(a.graph(), 1) + r := readRemoteRecord(store, row.Remote) + r.Settled = true + r.Finished = true + r.AckPending = true + if err := writeRemoteRecord(store, *r); err != nil { + t.Fatal(err) + } + _ = store.Close() + b := open() + defer b.Close() + select { + case got := <-remote.ack: + if got.Attempt != r.Attempt { + t.Fatalf("wrong ack: %+v", got) + } + case <-time.After(time.Second): + t.Fatal("sidecar acknowledgement was not sent on reopen") + } +} + +// R7-10 and contract 11 give the local worker a clean record of effects in its own tree. +func TestRunItHereStartsWithAFreshRecord(t *testing.T) { + taskComputerChoiceHome(t) + ready := make(chan struct{}) + remote := &scriptedTaskEngine{start: func(ctx context.Context, spec RunSpec) RunSummary { + r := remoteRecordFor(spec) + r.Unreachable = true + if err := spec.SaveRemote(r, true); err != nil { + t.Error(err) + } + if err := spec.OnRemoteStep(1, json.RawMessage(`{"kind":"step","step":1,"command":"remote-only effect"}`)); err != nil { + t.Error(err) + } + close(ready) + <-ctx.Done() + return RunSummary{Outcome: "interrupted", KeepCopy: true} + }} + a, local := remoteTaskAgent(t, remote, nil) + if _, _, _, err := a.StartTaskOn(context.Background(), "Repair export", false, "work computer"); err != nil { + t.Fatal(err) + } + <-ready + if _, err := a.RunTaskHere(context.Background(), 1); err != nil { + t.Fatal(err) + } + <-local.entered + defer endBeltRun(t, a, local) + local.mu.Lock() + dir := plandb.TaskDir(filepath.Dir(local.spec.Store.Path()), local.spec.Store.RootID()) + local.mu.Unlock() + raw, err := os.ReadFile(filepath.Join(dir, "trajectory.jsonl")) + if err != nil && !os.IsNotExist(err) { + t.Fatal(err) + } + if len(raw) != 0 { + t.Fatalf("local worker inherited remote effects: %s", raw) + } + archives, err := filepath.Glob(filepath.Join(dir, "trajectory-*.jsonl")) + if err != nil { + t.Fatal(err) + } + if len(archives) != 1 { + t.Fatalf("abandoned record not archived: %v", archives) + } + saved, err := os.ReadFile(archives[0]) + if err != nil || !strings.Contains(string(saved), "remote-only effect") { + t.Fatalf("remote record lost: %s, %v", saved, err) + } +} diff --git a/internal/session/task_remote_control.go b/internal/session/task_remote_control.go index e78918128d..ac524e2c8f 100644 --- a/internal/session/task_remote_control.go +++ b/internal/session/task_remote_control.go @@ -4,6 +4,8 @@ import ( "context" "errors" "fmt" + "os" + "path/filepath" "strconv" "time" @@ -73,133 +75,167 @@ func (a *Agent) continueRemoteRun(ctx context.Context, kept TaskNotice) (string, // local work. The copy comparison and result restore share remoteMu, so a late // result cannot pass between the comparison and the abandonment write. func (a *Agent) RunTaskHere(ctx context.Context, row uint64) (string, error) { - epoch, admissionErr := a.beltAdmissionEpoch() - if admissionErr != nil { - return "", admissionErr + epoch, err := a.beltAdmissionEpoch() + if err != nil { + return "", err } if a.config.TaskComputers == nil || chatRunEngine == nil { return "", errRunRoadUnavailable } a.lockBeltStart() + defer a.beltStartMu.Unlock() if err := a.checkBeltAdmission(epoch); err != nil { - a.beltStartMu.Unlock() return "", err } g := a.graph() + replacement, err := a.prepareTaskHere(ctx, g, row) + if err != nil { + return "", err + } + // Cleanup is best effort; the durable abandonment already excludes every late result. + go func(record TaskRemoteRecord, engine TaskRunEngine) { + stopCtx, cancel := context.WithTimeout(context.Background(), TaskComputerControlWithin) + defer cancel() + _ = engine.Abandon(stopCtx, record) + }(*replacement.record, replacement.engine) + if err := replacement.join(ctx); err != nil { + return "", err + } + a.beltMu.Lock() + busy := a.beltRun != nil + a.beltMu.Unlock() + if busy { + return "", errors.New("another task started before this task could run here") + } + return a.startTaskHere(g, replacement) +} + +type taskHereReplacement struct { + kept TaskNotice + live *beltRun + store *plandb.Store + record *TaskRemoteRecord + engine TaskRunEngine + tree taskTree + brief string +} + +// Preparation checks the untouched copy and durably fences the old attempt under its copy lock. +func (a *Agent) prepareTaskHere(ctx context.Context, g *TaskGraph, row uint64) (*taskHereReplacement, error) { kept, found := runRowOf(g, row) if !found || kept.Remote == nil || kept.Remote.Abandoned { - a.beltStartMu.Unlock() - return "", errors.New("this task is not waiting on another computer") + return nil, errors.New("this task is not waiting on another computer") } a.beltMu.Lock() live := a.beltRun a.beltMu.Unlock() if live != nil && live.row != row { - a.beltStartMu.Unlock() - return "", errors.New("another task is already running in this conversation") + return nil, errors.New("another task is already running in this conversation") } - var store *plandb.Store - var record *TaskRemoteRecord - var engine TaskRunEngine - var err error - if live != nil { - live.remoteMu.Lock() - record = cloneTaskRemote(live.remote) - engine = live.remoteEngine - store = live.store - } else { - store, err = openRemoteTaskPlan(g, row) - if err == nil { - record = readRemoteRecord(store, kept.Remote) - engine, err = a.taskExecutorEngine(ctx, record.Executor) - } + r := &taskHereReplacement{kept: kept, live: live} + if err := r.openRecord(ctx, a, g); err != nil { + r.releaseRecord() + return nil, err } - unlock := func() { - if live != nil { - live.remoteMu.Unlock() - } else if store != nil { - _ = store.Close() - } - a.beltStartMu.Unlock() + defer r.releaseRecord() + if r.store.RootID() != strconv.FormatUint(row, 10) { + return nil, errors.New("this task's saved plan does not match its number") } - if err == nil && store.RootID() != strconv.FormatUint(row, 10) { - err = errors.New("this task's saved plan does not match its number") + if !r.mayStartHere() { + return nil, errors.New("the other computer is still answering; stop this task before running it here") } + var err error + r.tree, err = reopenWorkspaceSet(kept.Copy, kept.WorkspaceSet, a.config.Place) if err != nil { - unlock() - return "", err + return nil, err } - if record == nil || engine == nil || (!record.StartFailed && !(record.Unreachable && !record.Finished) && kept.State != TaskInterrupted) { - unlock() - return "", errors.New("the other computer is still answering; stop this task before running it here") + if err := r.engine.CheckCopy(ctx, r.tree.dir, r.record.Fingerprint); err != nil { + return nil, err } - tree, err := reopenWorkspaceSet(kept.Copy, kept.WorkspaceSet, a.config.Place) - if err != nil { - unlock() - return "", err + r.record.Abandoned, r.record.AckPending = true, true + if err := writeRemoteRecord(r.store, *r.record); err != nil { + return nil, err } - if err = engine.CheckCopy(ctx, tree.dir, record.Fingerprint); err != nil { - unlock() - return "", err + if live != nil { + live.remote = cloneTaskRemote(r.record) } - record.Abandoned, record.AckPending = true, true - if err = writeRemoteRecord(store, *record); err != nil { - unlock() - return "", err + r.kept.Remote = cloneTaskRemote(r.record) + r.kept.Computer = "" + r.kept.Unreachable = false + a.publishRunRow(g, r.kept) + if root := r.store.Task(r.store.RootID()); root != nil { + r.brief = root.Description } - if live != nil { - live.remote = cloneTaskRemote(record) + return r, nil +} + +// A live record shares the watcher's fence; an archived record owns a separate store handle. +func (r *taskHereReplacement) openRecord(ctx context.Context, a *Agent, g *TaskGraph) error { + if r.live != nil { + r.live.remoteMu.Lock() + r.record = cloneTaskRemote(r.live.remote) + r.engine = r.live.remoteEngine + r.store = r.live.store + return nil } - kept.Remote = cloneTaskRemote(record) - kept.Computer = "" - kept.Unreachable = false - a.publishRunRow(g, kept) - brief := "" - if root := store.Task(store.RootID()); root != nil { - brief = root.Description + var err error + r.store, err = openRemoteTaskPlan(g, r.kept.ID) + if err != nil { + return err } - if live != nil { - live.remoteMu.Unlock() - } else if store != nil { - _ = store.Close() + r.record = readRemoteRecord(r.store, r.kept.Remote) + if r.record == nil { + return errors.New("this task is not waiting on another computer") } - // Keep start admission while joining the abandoned watcher. No new task may - // archive this plan or retire its copy between the fence and its replacement. - defer a.beltStartMu.Unlock() - // Network cleanup is best effort and cannot delay the person's local work. - go func() { - stopCtx, cancel := context.WithTimeout(context.Background(), TaskComputerControlWithin) - defer cancel() - _ = engine.Abandon(stopCtx, *record) - }() - if live != nil { - if live.cut != nil { - live.cut() - } - select { - case <-live.over: - case <-ctx.Done(): - return "", ctx.Err() - } + r.engine, err = a.taskExecutorEngine(ctx, r.record.Executor) + return err +} +func (r *taskHereReplacement) releaseRecord() { + if r.live != nil { + r.live.remoteMu.Unlock() + } else if r.store != nil { + _ = r.store.Close() } - a.beltMu.Lock() - busy := a.beltRun != nil - a.beltMu.Unlock() - if busy { - return "", errors.New("another task started before this task could run here") +} +func (r *taskHereReplacement) mayStartHere() bool { + return r.record != nil && r.engine != nil && (r.record.StartFailed || (r.record.Unreachable && !r.record.Finished) || r.kept.State == TaskInterrupted) +} + +// The start lock remains held while joining, so another task cannot retire this copy first. +func (r *taskHereReplacement) join(ctx context.Context) error { + if r.live == nil { + return nil + } + if r.live.cut != nil { + r.live.cut() } - plan, newStore, err := a.openBeltRunStore(g, g.planPath(), strconv.FormatUint(row, 10), kept.Title, brief, false) + select { + case <-r.live.over: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +// The replacement adopts the ready copy and begins with a fresh local trajectory. +func (a *Agent) startTaskHere(g *TaskGraph, r *taskHereReplacement) (string, error) { + row := r.kept.ID + if err := archiveRemoteTrajectory(g.planPath(), row); err != nil { + return "", err + } + plan, store, err := a.openBeltRunStore(g, g.planPath(), strconv.FormatUint(row, 10), r.kept.Title, r.brief, false) if err != nil { return "", err } runCtx, cut := context.WithCancel(a.rootContext()) - run := &beltRun{plan: plan, store: newStore, root: newStore.RootID(), row: row, title: kept.Title, worker: record.NamedModel, workspace: tree.dir, ground: tree.ground, tree: tree, stand: taskStand{dir: tree.ground, mode: tree.mode}, cut: cut, born: a.taskClockNow(), over: make(chan struct{})} + run := &beltRun{plan: plan, store: store, root: store.RootID(), row: row, title: r.kept.Title, worker: r.record.NamedModel, workspace: r.tree.dir, ground: r.tree.ground, tree: r.tree, stand: taskStand{dir: r.tree.ground, mode: r.tree.mode}, cut: cut, born: a.taskClockNow(), over: make(chan struct{})} a.installBeltRun(g, run) + kept := r.kept kept.State, kept.Report, kept.Ending, kept.Stopped = TaskRunning, "", "", false kept.StartedAt, kept.EndedAt = run.born, time.Time{} kept.Computer, kept.Unreachable, kept.RanElsewhere = "", false, false a.publishRunRow(g, kept) - go a.driveBeltRun(runCtx, chatRunEngine, run, a.beltRunSpec(run, brief)) + go a.driveBeltRun(runCtx, chatRunEngine, run, a.beltRunSpec(run, r.brief)) return "running " + taskStopName(row, kept.Title) + " here", nil } @@ -233,3 +269,25 @@ func openRemoteTaskPlan(g *TaskGraph, row uint64) (*plandb.Store, error) { } return nil, errors.New("this task's saved plan is no longer available") } + +// A local replacement must not resume effects that happened only in the abandoned copy. +func archiveRemoteTrajectory(plan string, row uint64) error { + path := filepath.Join(plandb.TaskDir(filepath.Dir(plan), strconv.FormatUint(row, 10)), "trajectory.jsonl") + if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) { + return nil + } else if err != nil { + return err + } + archive, err := os.CreateTemp(filepath.Dir(path), "trajectory-abandoned-*.jsonl") + if err != nil { + return err + } + if err := archive.Close(); err != nil { + return err + } + if err := os.Rename(path, archive.Name()); err != nil { + _ = os.Remove(archive.Name()) + return err + } + return nil +} diff --git a/internal/session/task_remote_money.go b/internal/session/task_remote_money.go index d5a592eb84..81b222f143 100644 --- a/internal/session/task_remote_money.go +++ b/internal/session/task_remote_money.go @@ -58,29 +58,10 @@ func appendRemoteUsage(path string, line UsageLine) error { memo = &remoteLedgerCursor{seen: map[string]bool{}} remoteUsageMemo.ledgers[path] = memo } - if _, err := f.Seek(memo.offset, io.SeekStart); err != nil { + newline, err := memo.readTail(f) + if err != nil { return err } - reader := bufio.NewReader(f) - for { - raw, err := reader.ReadBytes('\n') - if err != nil { - if err != io.EOF { - return err - } - if len(raw) > 0 { - if _, err := f.Write([]byte{'\n'}); err != nil { - return err - } - } - break - } - memo.offset += int64(len(raw)) - var old UsageLine - if json.Unmarshal(raw, &old) == nil && old.RemoteEvent != "" { - memo.seen[old.RemoteEvent] = true - } - } if memo.seen[line.RemoteEvent] { return nil } @@ -88,6 +69,9 @@ func appendRemoteUsage(path string, line UsageLine) error { if err != nil { return err } + if newline { + raw = append([]byte{'\n'}, raw...) + } if _, err = f.Write(append(raw, '\n')); err != nil { return err } @@ -100,6 +84,36 @@ func appendRemoteUsage(path string, line UsageLine) error { return nil } +// readTail recognizes complete EOF objects without turning a replay into another charge. +// A partial object is rolled back to the last complete boundary before any new write. +func (memo *remoteLedgerCursor) readTail(f *os.File) (bool, error) { + if _, err := f.Seek(memo.offset, io.SeekStart); err != nil { + return false, err + } + reader := bufio.NewReader(f) + for { + raw, err := reader.ReadBytes('\n') + if err != nil && err != io.EOF { + return false, err + } + if len(raw) == 0 { + return false, nil + } + var old UsageLine + readable := json.Unmarshal(raw, &old) == nil + if readable && old.RemoteEvent != "" { + memo.seen[old.RemoteEvent] = true + } + if err == io.EOF { + if readable { + return true, nil + } + return false, f.Truncate(memo.offset) + } + memo.offset += int64(len(raw)) + } +} + // The transcript records the same event identity before changing live totals. // A crash on either side of that write replays exactly one charge on reopen. func (a *Agent) foldRemoteCharge(event string, charge RunCharge) error { diff --git a/internal/session/task_remote_record.go b/internal/session/task_remote_record.go index 22a9fa5669..9b933a3f2e 100644 --- a/internal/session/task_remote_record.go +++ b/internal/session/task_remote_record.go @@ -203,35 +203,9 @@ type remoteStepMirror struct { func (mirror *remoteStepMirror) append(store *plandb.Store, attempt string, seq uint64, raw json.RawMessage) error { path := filepath.Join(plandb.TaskDir(filepath.Dir(store.Path()), store.RootID()), "trajectory.jsonl") if mirror.seen == nil { - existing, err := os.ReadFile(path) - if err != nil && !os.IsNotExist(err) { + if err := mirror.readPrefix(path, attempt); err != nil { return err } - mirror.readBytes += int64(len(existing)) - // An interrupted append cannot swallow the next complete step. - if len(existing) > 0 && existing[len(existing)-1] != '\n' { - end := bytes.LastIndexByte(existing, '\n') + 1 - if err := os.Truncate(path, int64(end)); err != nil { - return err - } - existing = existing[:end] - } - mirror.seen = map[uint64]bool{} - scan := bufio.NewScanner(bytes.NewReader(existing)) - scan.Buffer(make([]byte, 4096), 1<<20) - for scan.Scan() { - var line struct { - Attempt string `json:"remote_attempt"` - Seq uint64 `json:"remote_seq"` - } - if json.Unmarshal(scan.Bytes(), &line) == nil && line.Attempt == attempt { - mirror.seen[line.Seq] = true - } - } - if err := scan.Err(); err != nil { - return err - } - } if mirror.seen[seq] { return nil @@ -242,7 +216,6 @@ func (mirror *remoteStepMirror) append(store *plandb.Store, attempt string, seq } delete(fields, "full_output") delete(fields, "children") - fields["kind"] = json.RawMessage(`"step"`) fields["remote_attempt"], _ = json.Marshal(attempt) fields["remote_seq"], _ = json.Marshal(seq) line, err := json.Marshal(fields) @@ -271,6 +244,40 @@ func (mirror *remoteStepMirror) append(store *plandb.Store, attempt string, seq return err } +// readPrefix loads replay identities once; ordinary appends then do only new work. +func (mirror *remoteStepMirror) readPrefix(path, attempt string) error { + existing, err := os.ReadFile(path) + if err != nil && !os.IsNotExist(err) { + return err + } + mirror.readBytes += int64(len(existing)) + // An interrupted append cannot swallow the next complete step. + if len(existing) > 0 && existing[len(existing)-1] != '\n' { + end := bytes.LastIndexByte(existing, '\n') + 1 + if err := os.Truncate(path, int64(end)); err != nil { + return err + } + existing = existing[:end] + } + mirror.seen = map[uint64]bool{} + scan := bufio.NewScanner(bytes.NewReader(existing)) + scan.Buffer(make([]byte, 4096), 1<<20) + for scan.Scan() { + var line struct { + Attempt string `json:"remote_attempt"` + Seq uint64 `json:"remote_seq"` + } + if json.Unmarshal(scan.Bytes(), &line) == nil && line.Attempt == attempt { + mirror.seen[line.Seq] = true + } + } + if err := scan.Err(); err != nil { + return err + } + + return nil +} + // ackRemoteRun follows local settlement, so B cannot discard an unreceived // change. A failed acknowledgement remains durable for the next reopen. func (a *Agent) ackRemoteRun(run *beltRun) { @@ -348,7 +355,7 @@ func (a *Agent) retrySavedRemoteAcks(g *TaskGraph) { rows := g.runRowsLocked() g.mu.Unlock() for _, row := range rows { - if row.Remote == nil || !row.Remote.AckPending { + if row.Remote == nil || row.Remote.Executor.Device == "" { continue } store, err := openRemoteTaskPlan(g, row.ID) @@ -372,3 +379,20 @@ func (run *beltRun) taskComputerName() string { } return "" } + +// Abandonment fences the result before the ordinary ending road is entered. +func (run *beltRun) remoteAbandoned() bool { + run.remoteMu.Lock() + defer run.remoteMu.Unlock() + return run.remote != nil && run.remote.Abandoned +} + +// A closing chat retains a remote watch that has not received its final receipt. +func (run *beltRun) remoteWaitingOnClose(closing bool) bool { + if !closing || run.remoteEngine == nil { + return false + } + run.remoteMu.Lock() + defer run.remoteMu.Unlock() + return run.remote == nil || !run.remote.Finished +} diff --git a/internal/taskplace/computers.go b/internal/taskplace/computers.go index 93e82c8ebe..e71abd3134 100644 --- a/internal/taskplace/computers.go +++ b/internal/taskplace/computers.go @@ -44,6 +44,12 @@ func (c *Computers) List(ctx context.Context) ([]session.TaskComputer, error) { } out = append(out, row) } + out = session.TaskComputerNames(out, c.ThisComputer()) + for i := range out { + if !out[i].CanTakeTasks { + out[i].Reason = UnsupportedTarget(out[i].Name) + } + } sort.Slice(out, func(i, j int) bool { if out[i].Name == out[j].Name { return out[i].Device < out[j].Device diff --git a/internal/taskplace/computers_r8_test.go b/internal/taskplace/computers_r8_test.go new file mode 100644 index 0000000000..2762f62d79 --- /dev/null +++ b/internal/taskplace/computers_r8_test.go @@ -0,0 +1,46 @@ +package taskplace_test + +import ( + "context" + "testing" + + "github.com/Agent-Field/codeaf/internal/directory" + "github.com/Agent-Field/codeaf/internal/identity" + "github.com/Agent-Field/codeaf/internal/taskdoor" + "github.com/Agent-Field/codeaf/internal/taskplace" +) + +type namedComputerDirectory struct { + directory.Client + rows directory.Listing +} + +func (d namedComputerDirectory) List(context.Context) (directory.Listing, error) { return d.rows, nil } + +// R7-12 lists the same labels used to distinguish devices on the devices page. +func TestPairedComputersWithTheSameNameUseDeviceLabels(t *testing.T) { + home := t.TempDir() + id, err := identity.Ensure(home) + if err != nil { + t.Fatal(err) + } + device, err := identity.LoadDevice(home) + if err != nil { + t.Fatal(err) + } + sealed, err := directory.SealName(directory.MetadataKey(id.CellKey()), "mac mini.local") + if err != nil { + t.Fatal(err) + } + c := taskplace.Computers{Home: home, Copies: taskdoor.CopyDeps{Identity: id, Device: device}, Directory: namedComputerDirectory{rows: directory.Listing{Devices: map[string]directory.Device{ + "dev_0123456789abcdef0123456789ab1111": {Name: sealed, Platform: "linux"}, + "dev_0123456789abcdef0123456789ab2222": {Name: sealed, Platform: "darwin"}, + }}}} + rows, err := c.List(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(rows) != 2 || rows[0].Name != "mac mini #1111" || rows[1].Name != "mac mini #2222" { + t.Fatalf("computers = %+v", rows) + } +} diff --git a/internal/tui3/devices.go b/internal/tui3/devices.go index 67d1a32075..163beca0d3 100644 --- a/internal/tui3/devices.go +++ b/internal/tui3/devices.go @@ -29,6 +29,7 @@ import ( "github.com/charmbracelet/x/ansi" "github.com/Agent-Field/codeaf/internal/chatlist" + "github.com/Agent-Field/codeaf/internal/devname" "github.com/Agent-Field/codeaf/internal/tui2/tokens" ) @@ -45,7 +46,6 @@ const ( devicesRevoked = "%s was removed — it can no longer reach your chats." devicesSelf = "this device" devicesOwnRow = "this device cannot remove itself — choose another row." - idTail = 4 ) // deviceCard is the list's state. Online comes from the feed when the card opens. @@ -226,7 +226,7 @@ func (d DeviceRow) before(o DeviceRow) bool { func (c *deviceCard) shownName(d DeviceRow) string { for _, o := range c.rowsOf { if o.ID != d.ID && o.Name == d.Name { - return d.Name + " #" + d.ID[max(len(d.ID)-idTail, 0):] + return devname.DistinctName(d.Name, d.ID) } } return d.Name From af2fc9ef36f027452a040ed5b00c83617fe92df5 Mon Sep 17 00:00:00 2001 From: Abir Abbas <abirabbas1998@gmail.com> Date: Sat, 31 Oct 2026 01:14:46 -0400 Subject: [PATCH 26/34] docs: quote every task-transfer refusal in the chat manual, with what to do Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../2085-task-on-another-computer.md | 15 ++ .../chat/a-dev-box-on-your-own-railway.md | 6 +- internal/manual/chat/adaptive-runs.md | 24 ++- .../manual/chat/tasks-on-another-computer.md | 189 +++++++++++++++++- internal/manual/chat_test.go | 19 ++ internal/manual/task_refusals_test.go | 37 ++++ 6 files changed, 275 insertions(+), 15 deletions(-) create mode 100644 internal/manual/task_refusals_test.go diff --git a/docs/changes/unreleased/2085-task-on-another-computer.md b/docs/changes/unreleased/2085-task-on-another-computer.md index 94e62d9b82..50a40cbd10 100644 --- a/docs/changes/unreleased/2085-task-on-another-computer.md +++ b/docs/changes/unreleased/2085-task-on-another-computer.md @@ -23,3 +23,18 @@ Everything is opt-in. With defaults left alone, pairing adds no relay traffic, and a local task's brief, records, the proposal card and the model's tool are byte-identical to before; the Run tasks on settings row is the only new thing a paired person sees. + +Acknowledged attempts retain bounded replay protection across serving restarts; +a removed computer also loses an existing task connection after the cached +permission expires. Result installation checks the moved original before +replacing it, and downloads leave the stop action available. Interrupted remote +work stays on the other computer so the original sent copy can run here. + +Mirrored records retain their original kinds, local fallback archives the remote +trajectory, and computers with the same name use the Devices labels. Ambiguous +names are refused with choices. Complete ledger entries missing a final newline +are deduplicated, and reopening inspects saved remote acknowledgements even when +the conversation checkpoint predates them. Unsupported conversations expose no +placement row through either settings tool. The manual includes exact refusal +sentences and recovery guidance; transfer timings are diagnostic rather than a +wall-clock test gate. diff --git a/internal/manual/chat/a-dev-box-on-your-own-railway.md b/internal/manual/chat/a-dev-box-on-your-own-railway.md index 839678dfe4..1c589fea71 100644 --- a/internal/manual/chat/a-dev-box-on-your-own-railway.md +++ b/internal/manual/chat/a-dev-box-on-your-own-railway.md @@ -151,14 +151,14 @@ to admit another computer yet; use the creating laptop. ## Send a task to my dev box A dev box also takes tasks. It starts `codeaf serve --take-tasks` whenever its -installed build lists that flag, so a chat on your laptop can run one task on the +installed build lists that option, so a chat on your laptop can run one task on the box while the conversation and transcript stay on the laptop: `/task --on <name> <brief>` names it for one task, and Settings → Run tasks on sends every task that can go. The box's synced provider keys pay, and the spend -appears on the laptop too. A box whose build predates the flag starts plain +appears on the laptop too. A box whose build predates the option starts plain `codeaf serve` and takes no tasks; sending one says `<name> is not taking tasks right now — run codeaf serve --take-tasks on it`, and -the box picks the flag up when its channel's build has it and it restarts. See +the box picks the option up when its channel's build has it and it restarts. See *Tasks on another computer* for what can go and what happens when a computer stops answering. diff --git a/internal/manual/chat/adaptive-runs.md b/internal/manual/chat/adaptive-runs.md index 84e7616833..2f9bf96a72 100644 --- a/internal/manual/chat/adaptive-runs.md +++ b/internal/manual/chat/adaptive-runs.md @@ -139,15 +139,9 @@ None of this happens without a folder to weigh in. A run with no `-w`, or a goal no file that exists, is not treated as small — it is treated as unmeasured, and every one of these passes behaves exactly as it did before any of it was read. -## Running one task without the screen — codeaf do, headless, from a script: what flags it takes, what it prints, what its last line and its exit code mean +## What flags does codeaf do take — run one task from a script -``` -codeaf do "<task>" -``` - -One job, nobody watching, then it exits. What you type **is** the goal — it is not reworded -on the way in, and where this conversation would stop and ask, a run with nobody at the -keyboard decides for itself and says on the record that it decided. +Run `codeaf do "<task>"` for one job without the screen. These options control that invocation. | flag | what it does | | --- | --- | @@ -167,6 +161,10 @@ keyboard decides for itself and says on the record that it decided. Flags may come after the task text, and the task itself may be piped in. +## What does the last line of codeaf do mean + +With plain output, `codeaf do` ends with a footer describing the completed work. `--json` prints one machine-readable object instead. + **Standard output is the answer and nothing else**: the deliverable, then `files:` with one absolute path under it per file, then `learned:` — what one worker told the others mid-flight — and last one footer line: @@ -185,6 +183,16 @@ piece of work as it starts and as it lands, and, when half a minute passes with report, a line like `still waiting: 1 task pending, 1 running · last call <model> 40s ago — 4m30s`. +## How do I run codeaf without the screen — headless exit codes and questions + +``` +codeaf do "<task>" +``` + +One job, nobody watching, then it exits. What you type **is** the goal — it is not reworded +on the way in, and where this conversation would stop and ask, a run with nobody at the +keyboard decides for itself and says on the record that it decided. + **The exit code is what a script reads.** `0` — the whole of it stands. `1` — nothing usable: it failed, the price was refused, or it stopped on a question. `2` — partial: the wall came first, the review rejected what was delivered, or parts of it did not land. diff --git a/internal/manual/chat/tasks-on-another-computer.md b/internal/manual/chat/tasks-on-another-computer.md index 23896fac54..8f119ac223 100644 --- a/internal/manual/chat/tasks-on-another-computer.md +++ b/internal/manual/chat/tasks-on-another-computer.md @@ -42,7 +42,11 @@ over an empty draft; accepting the proposal sends that choice. The line says `runs on <name>`. This line and cycle key are absent from ordinary local proposals until the setting or proposal names a computer. The model's `computer` argument is available only while **Run tasks on** names a paired -computer. A misspelt name is refused with +computer. Two computers with the same name use their Devices labels, such as +`mac mini #1111` and `mac mini #2222`. Include that label when choosing one. +An ambiguous bare name says +`"<name>" matches more than one paired computer — choose one of: <names>`. +A misspelt name is refused with `no paired computer is named "<name>" — your computers are: <names>`. Both computers must run Linux or macOS, and the task needs its own copy. Sending @@ -97,7 +101,8 @@ Choosing `run it here` abandons the old attempt and starts fresh local work in the unchanged copy here. A late result from the abandoned attempt never lands. The placement words disappear and the receipt says `running task <number> <title> here`. If this copy has changed, codeaf keeps -both copies and refuses to overwrite it. A computer that dropped may still be +both copies and refuses to overwrite it. The abandoned task’s record is archived; +the local worker starts with a fresh record of effects in its own copy. A computer that dropped may still be working; a request to stop it is best effort. ## Run tasks on — when the model gets the computer argument @@ -106,7 +111,7 @@ In `/settings` → Tasks, **Run tasks on** follows **Task model**. Its hint is `where a task goes when you do not name a computer`. It offers `this computer` (the default), then eligible paired computers by shown name. The row reads the computer list only when you open it. It is absent on unpaired, quiet or Windows -homes. A removed or unsupported saved choice displays +homes, including from the model’s `settings` and `change_setting` tools. A removed or unsupported saved choice displays `this computer`. Changing the row affects the next task, not work already running. An explicit `/task --on`, proposal-card computer choice, or model-supplied @@ -121,7 +126,8 @@ Run `codeaf serve --take-tasks` on the paired computer that should take tasks. I `takes tasks from your paired computers`. Pairing with the same identity is the permission; no separate machine-access grant is needed for tasks. Quiet or unpaired homes do not offer this door. Linux and macOS are supported on both -ends; Windows is not yet. Windows serving says +ends; Windows is not yet. A named unsupported target says +`<computer> cannot take tasks from other computers yet`. Windows serving says `this computer cannot take tasks from other computers yet`. At most two attempts run on that computer at once. A third is refused with @@ -156,3 +162,178 @@ including another task on the same computer. The refusal is This limit applies even though the receiving computer can take two attempts from separate conversations. Remote tasks cannot join more work into the instruction already sent. + +## Could not check your devices right now + +`could not check your devices right now` means the task computer could not +refresh your devices list. It refuses new work and ends the connection rather +than trust an old permission. Check sync connectivity on both computers, then +retry. Removing a device also ends an existing task watch when its cached +permission expires, within about a minute; the worker itself may still run. +`this computer was removed from your devices` requires pairing that computer +again before it can send tasks. + +## Could not confirm this is my task computer + +`could not confirm this is <computer>` or `could not confirm this computer` +means the connection could not prove it was the chosen paired computer. +Check its entry in Devices, update codeaf on both ends, and pair again if needed. +Do not substitute another computer just because its name looks the same. +`<computer> runs a different codeaf that cannot take this task — update both` +requires updating both ends before retrying. + +## The task copy could not be sent + +`the task copy changed while it was being sent; try again` keeps the copy here. +Finish or pause the edits, then retry the task. +`task copies need private storage and a relay blob store` means this chat's +task transfer is not set up; reopen a supported paired chat with sync available. +`a home folder or a folder above it cannot be sent as a task copy` requires +choosing a project folder below your home, with an independent task copy. +No task is silently redirected elsewhere; use `run it here` when offered. + +## The task copy cell or head is not readable + +`the task copy's cell is not readable` and +`the task copy's head is not readable` mean the saved transfer information +cannot identify a usable task copy. Keep the copy here and the other computer's +work, update both ends, then retry from the task's own controls. +`the sent task copy's fingerprint is missing` means codeaf cannot safely +compare the returned work with what it sent. Keep both copies and choose which +change to bring home; do not hand-edit the saved comparison. +`the task copy's repository link is not a file` or +`the task copy is missing its independent history` requires keeping the copies +and preparing a new independent task copy before retrying. + +## The task destination already contains files + +`the task's destination already contains files` refuses to replace existing +files while receiving a task copy. Keep that folder intact, inspect the old +task's result, and retry with a fresh task copy. Do not clear a folder while a +worker may still use it. +`the task copy changed here while the other computer worked; both copies were kept` +also covers an edit made just as a returned result was being installed. Your +edit stays here and the other result stays available; inspect both before +choosing what to bring home. + +## Could not put the result in place; the earlier copy is kept + +`could not put the result in place; the earlier copy is kept at <path>: <reason>` +means installation and its rollback could not finish. Preserve the named path: +it holds your earlier copy. Check free disk space and folder permissions, then +inspect both copies before moving files or retrying. Do not delete the named +folder as temporary clutter. +`The result could not be brought from <computer>; the copy here was kept` +means the remote task has no usable returned copy. Keep your local copy, restore +connectivity, and inspect the task on the other computer before choosing +`run it here`. + +## That task attempt id is not readable, still stopping, or already finished + +`that task attempt id is not readable` means the saved attempt cannot be +identified; reopen the chat with an updated build and keep both task copies. +`that task is still stopping` means an abandoned worker has not finished +stopping; wait rather than start the same attempt again. +`that task has already finished` refuses replay of an acknowledged attempt. +Completed attempts keep compact replay protection for the newest 1,000 +acknowledgements, even after restarting the serving computer. Start a new task +if you want the work performed again; resending its old attempt cannot rerun it. +These receipts describe the saved attempt, not a reason to discard its work. + +## That task request is not readable + +`that task request is not readable` means the receiving computer cannot read +the request. Update both ends, reopen the chat, and retry through the normal +task controls. +`choose landed or abandoned for this task` concerns the saved acknowledgement +of a result. Keep both copies and reopen the chat so codeaf can retry that +acknowledgement; do not invent an acknowledgement or resend the old start. +`taskdoor: a frame exceeds the 1048576-byte limit` means a task message exceeded +the transfer limit. Keep the copies, update both builds, and use a smaller +instruction or bounded task output before starting a new attempt. + +## The other computer no longer has this task's answer + +`the other computer no longer has this task's answer` means the saved watch +found no retained attempt. Keep the copy here; inspect the other computer's +task files before choosing `run it here`. +`the other computer sent an unreadable task event` means an event cannot be +used safely. Update both ends and reopen the chat to follow its saved attempt; +do not start another worker while the first may still run. +Reopening follows the original attempt and never repeats its tools by itself. + +## The task's remote record cannot be saved here + +`the task's remote record cannot be saved here` or +`the task's remote record is missing` means the conversation cannot keep the +information needed to follow work elsewhere. Keep both task copies, check +local disk space and permissions, and reopen the saved chat before retrying. +`could not save the task result: <reason>` similarly requires preserving the +other computer's task folder and fixing the named storage problem. +`the returned task copy must be landed by this computer` means the result +must go through the owning chat's ordinary landing or Review and Apply here; +open that chat to bring the work home. + +## The task was interrupted before it left an answer + +`the task was interrupted on this computer before it left an answer`, +`the task ended without an answer`, or `the task ended without a result` +means no complete answer was left by the worker. Its partial work stayed on +the other computer. The sent copy here remains unchanged so `run it here` +can start fresh; inspect partial work on the other computer if you need it. +`The work could not be sent back: <reason>` keeps that work on the other +computer too. Fix the stated transfer problem and keep the copy there until +you have brought the change home. A normal stop still brings partial work +back through the usual landing road. + +## Could not record the task's child + +`could not record the task's child: <reason>` means the serving computer could +not safely remember the worker it started. It stops that worker rather than +lose track of it. Inspect the reason, update both builds, and retry with a new +task after checking the retained copy. +Recovery may instead say +`the recorded process does not name this task's work folder`, +`the recorded process does not name this task's private record`, +`the child's process start is not readable`, +`the recorded process is not this task's child`, or +`this task's child is no longer running`. +These refusals prevent attaching to an unrelated process. Keep the task copy; +an interrupted attempt can be replaced with `run it here` after inspecting it. + +## The task's saved event is too large + +`the task's saved event is too large` means a recorded step or charge cannot +fit the bounded task journal. Keep the other computer's task folder and inspect +its output; reduce oversized output before starting a new task. +Saved journal damage can say +`taskdoor: the saved task events are out of order`, +`taskdoor: saved events follow the task's finish`, +`taskdoor: only task events belong in a journal`, +`taskdoor: unknown task outcome`, or +`taskdoor: that task has already finished`. +Preserve the record, update codeaf, and inspect the retained work before +choosing a fresh local attempt. Do not repair the journal by deleting lines. + +## This computer is already taking tasks + +`this computer is already taking tasks` means a serving process already owns +the task folder. Use that process or close it before starting another one. +`` this machine is already connected to sync as <connection> — there is only one of it, so close the other `codeaf serve` `` +requires closing the duplicate serving process. +`<computer> is already running two tasks` requires waiting for a slot; each +remote task still runs alone in its owning conversation. +Setup errors such as `taskdoor: a door needs a relay, roster and executor`, +`taskdoor: this device does not belong to the identity`, +`taskdoor: an attempt store folder is required`, or +`taskdoor: attempt <id> is not a folder` require checking the paired profile +and its task storage before starting `codeaf serve --take-tasks` again. + +## The task door says empty frame or a frame needs a type + +`taskdoor: empty frame` or `taskdoor: a frame needs a type` means the task +computer sent an unreadable task message. The connection cannot safely start or +follow that request. Update codeaf on both computers, check the chosen computer +in Devices, and reopen the chat before retrying through its task controls. +Keep the task copies while checking whether the original attempt started. +A connection failure never grants permission to repeat an admitted attempt. diff --git a/internal/manual/chat_test.go b/internal/manual/chat_test.go index 9b8593a6d2..41e5210ee5 100644 --- a/internal/manual/chat_test.go +++ b/internal/manual/chat_test.go @@ -34,6 +34,25 @@ func TestTheChatManualAnswersTheQuestionsPeopleAsk(t *testing.T) { page string }{ // Task placement must answer DESIGN contract 16 in the person's words. + {"taskdoor empty frame when starting a task", "tasks-on-another-computer"}, + {"taskdoor a frame needs a type", "tasks-on-another-computer"}, + {"could not check your devices right now", "tasks-on-another-computer"}, + {"could not confirm this is my task computer", "tasks-on-another-computer"}, + {"the task copy could not be sent", "tasks-on-another-computer"}, + {"the task copy cell or head is not readable", "tasks-on-another-computer"}, + {"the task destination already contains files", "tasks-on-another-computer"}, + {"could not put the result in place earlier copy kept", "tasks-on-another-computer"}, + {"the sent task copy fingerprint is missing", "tasks-on-another-computer"}, + {"that task attempt id is not readable", "tasks-on-another-computer"}, + {"that task is already finished", "tasks-on-another-computer"}, + {"that task request is not readable", "tasks-on-another-computer"}, + {"the other computer no longer has this task answer", "tasks-on-another-computer"}, + {"the other computer sent an unreadable task event", "tasks-on-another-computer"}, + {"the task remote record cannot be saved here", "tasks-on-another-computer"}, + {"the task was interrupted before it left an answer", "tasks-on-another-computer"}, + {"could not record the task child", "tasks-on-another-computer"}, + {"the task saved event is too large", "tasks-on-another-computer"}, + {"this computer is already taking tasks", "tasks-on-another-computer"}, {"can a task run on my other computer", "tasks-on-another-computer"}, {"run a task on my mac mini", "tasks-on-another-computer"}, {"what does running on mean", "tasks-on-another-computer"}, diff --git a/internal/manual/task_refusals_test.go b/internal/manual/task_refusals_test.go new file mode 100644 index 0000000000..70d072972a --- /dev/null +++ b/internal/manual/task_refusals_test.go @@ -0,0 +1,37 @@ +package manual + +import ( + "os" + "strings" + "testing" +) + +// R7-16 and contract 16 keep the door's actionable refusals in the shipped chat manual. +func TestTaskComputerRefusalsExplainWhatToDo(t *testing.T) { + raw, err := os.ReadFile("chat/tasks-on-another-computer.md") + if err != nil { + t.Fatal(err) + } + for _, sentence := range []string{ + "taskdoor: empty frame", "taskdoor: a frame needs a type", "could not check your devices right now", "could not confirm this computer", "could not confirm this is <computer>", + "this computer is already taking tasks", "that task attempt id is not readable", "that task is still stopping", "that task has already finished", + "that task request is not readable", "choose landed or abandoned for this task", + "the task copy's cell is not readable", "the task copy's head is not readable", "task copies need private storage and a relay blob store", + "the task copy changed while it was being sent; try again", "the task's destination already contains files", + "could not put the result in place; the earlier copy is kept at <path>: <reason>", "the sent task copy's fingerprint is missing", + "a home folder or a folder above it cannot be sent as a task copy", + "the task's remote record cannot be saved here", "the task's remote record is missing", "the other computer no longer has this task's answer", + "the other computer sent an unreadable task event", "The result could not be brought from <computer>; the copy here was kept", + "the task was interrupted on this computer before it left an answer", "the task ended without an answer", "the task ended without a result", + "could not record the task's child: <reason>", "the task's saved event is too large", "The work could not be sent back: <reason>", + "the recorded process does not name this task's work folder", "the recorded process does not name this task's private record", + "the child's process start is not readable", "the recorded process is not this task's child", "this task's child is no longer running", + "could not save the task result: <reason>", "the returned task copy must be landed by this computer", + } { + t.Run(sentence, func(t *testing.T) { + if !strings.Contains(string(raw), "`"+sentence+"`") { + t.Errorf("missing exact refusal and recovery guidance: %s", sentence) + } + }) + } +} From 4a870ec65198431c2f93c47e0ce5edaef42cedc5 Mon Sep 17 00:00:00 2001 From: Abir Abbas <abirabbas1998@gmail.com> Date: Sat, 31 Oct 2026 01:14:46 -0400 Subject: [PATCH 27/34] fix: meet the settings, chord, off-loop, guarded-goroutine and complexity laws Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- cmd/codeaf/serve_tasks.go | 5 +- internal/config/derivation_test.go | 1 + internal/config/testdata/profile-keys.ledger | 1 + internal/session/task.go | 36 +++- internal/session/task_remote_watch.go | 213 +++++++++++-------- internal/session/task_reopen_r8_test.go | 90 ++++++++ internal/session/task_run_belt.go | 199 +++++++++-------- internal/session/task_run_continue.go | 26 ++- internal/session/task_run_recover.go | 75 ++++--- internal/session/task_watch_r8_test.go | 24 +++ internal/tui3/chordregistry_test.go | 1 + internal/tui3/taskcommand.go | 14 +- 12 files changed, 446 insertions(+), 239 deletions(-) create mode 100644 internal/session/task_reopen_r8_test.go create mode 100644 internal/session/task_watch_r8_test.go diff --git a/cmd/codeaf/serve_tasks.go b/cmd/codeaf/serve_tasks.go index 21fc208fcc..b7e5003d14 100644 --- a/cmd/codeaf/serve_tasks.go +++ b/cmd/codeaf/serve_tasks.go @@ -11,6 +11,7 @@ import ( "github.com/Agent-Field/codeaf/internal/blobstore" "github.com/Agent-Field/codeaf/internal/cellstore" + "github.com/Agent-Field/codeaf/internal/guard" "github.com/Agent-Field/codeaf/internal/home" "github.com/Agent-Field/codeaf/internal/identity" "github.com/Agent-Field/codeaf/internal/pair" @@ -41,12 +42,12 @@ func serveTasks(ctx context.Context, service string) func() { config.Say = func(line string) { fmt.Fprintln(os.Stdout, line) } ctx, cancel := context.WithCancel(ctx) done := make(chan struct{}) - go func() { + guard.Go("serve/tasks", func() { defer close(done) if err := taskdoor.Serve(ctx, config); err != nil && ctx.Err() == nil { fmt.Fprintln(os.Stderr, "could not take tasks from your paired computers:", err) } - }() + }) return func() { cancel(); <-done } } diff --git a/internal/config/derivation_test.go b/internal/config/derivation_test.go index c97859a4db..52b34cc228 100644 --- a/internal/config/derivation_test.go +++ b/internal/config/derivation_test.go @@ -115,6 +115,7 @@ func TestEveryRowIsGroupedNamedAndReadable(t *testing.T) { // wave apiece — so what is named here is the thing the reader touches, not the // spelling of the key. var settingReaders = map[string]string{ + KeyTaskRunsOn: "TaskRunsOnAt", KeyKnowledgeModel: "KnowledgeModelAt", KeyChatModelRotation: "ChatModelRotationAt", KeyWork: "workMode", diff --git a/internal/config/testdata/profile-keys.ledger b/internal/config/testdata/profile-keys.ledger index 80bbb17d1d..14dea5aec2 100644 --- a/internal/config/testdata/profile-keys.ledger +++ b/internal/config/testdata/profile-keys.ledger @@ -99,6 +99,7 @@ task.min_free_mb task.model task.parallel task.repair_rounds +task.runs_on task.settle task.start teams.cap_usd_day diff --git a/internal/session/task.go b/internal/session/task.go index a3be8ad1c2..4ee90c0995 100644 --- a/internal/session/task.go +++ b/internal/session/task.go @@ -725,10 +725,8 @@ func (a *Agent) stageTask(ctx context.Context, args json.RawMessage) bare.Staged } return bare.Settled(problem, true) } - if spec.computer != "" { - if _, err := a.resolveTaskComputer(ctx, spec.computer, taskStand{}, "", false); err != nil { - return bare.Settled(err.Error(), true) - } + if err := a.checkProposedTaskComputer(ctx, spec); err != nil { + return bare.Settled(err.Error(), true) } // THE DOOR REFUSALS, before a card or a slot. A proposal that left out the // program the person named, a trivial ask and a depends_on that can never @@ -985,7 +983,7 @@ func (a *Agent) commitProposalToRun(ctx context.Context, p *stagedProposal, spec return runDidNotStart(p.id, err), true, true } - if !(bashBeltAsked() || spec.via != "" || a.taskComputerPreference() || (a.config.TaskComputers != nil && spec.computer != "")) || chatRunEngine == nil || a.config.InTask { + if !a.proposalUsesRunRoad(spec) { return "", false, false } a.mu.Lock() @@ -1017,10 +1015,7 @@ func (a *Agent) commitProposalToRun(ctx context.Context, p *stagedProposal, spec placement.maxSteps, placement.noProgress = spec.maxSteps, spec.noProgress ctx = withTaskPlacement(ctx, placement) elsewhere = withReport(elsewhere, placement.sentence) - description := proposalBrief(spec, via) - if placement.computer != nil && len(spec.checks) > 0 { - description += "\n\nChecks\n\n" + strings.Join(spec.checks, "\n") - } + description := proposalRunDescription(spec, via, placement) // THE RUN OUTLIVES THE TURN THAT LAUNCHED IT, AND NOT THE CONVERSATION. // This context is the turn's, and the turn cancels it on its way out // (agent.go, `defer cancel(nil)`); a run driven under it would be stopped @@ -1881,3 +1876,26 @@ func (a *Agent) admitWorkspaceProposal(ctx context.Context, stand taskStand, spe func singletonRunFallback(err error, stand taskStand) bool { return stand.workspaceSet == nil && errors.Is(err, errRunRoadUnavailable) } + +// Placement validation precedes the proposal card without adding roster work to local proposals. +func (a *Agent) checkProposedTaskComputer(ctx context.Context, spec taskSpec) error { + if spec.computer == "" { + return nil + } + _, err := a.resolveTaskComputer(ctx, spec.computer, taskStand{}, "", false) + return err +} + +// The run road keeps the existing fallback condition and short-circuit order. +func (a *Agent) proposalUsesRunRoad(spec taskSpec) bool { + return (bashBeltAsked() || spec.via != "" || a.taskComputerPreference() || (a.config.TaskComputers != nil && spec.computer != "")) && chatRunEngine != nil && !a.config.InTask +} + +// Checks belong only in the instruction transported to another computer. +func proposalRunDescription(spec taskSpec, via *delegate.Delegate, placement taskPlacement) string { + description := proposalBrief(spec, via) + if placement.computer != nil && len(spec.checks) > 0 { + description += "\n\nChecks\n\n" + strings.Join(spec.checks, "\n") + } + return description +} diff --git a/internal/session/task_remote_watch.go b/internal/session/task_remote_watch.go index 5b0fcbb8d6..5289445970 100644 --- a/internal/session/task_remote_watch.go +++ b/internal/session/task_remote_watch.go @@ -44,111 +44,142 @@ func FollowTaskWatch(ctx context.Context, clock TaskWatchClock, from uint64, wat if clock == nil { clock = taskWatchWall{} } - last := clock.Now() - delay := time.Second - unreachable := false - notice := func() { - if !unreachable && clock.Now().Sub(last) >= TaskComputerSilentAfter { - unreachable = true - reachability(true) - } - } + state := taskWatchState{clock: clock, last: clock.Now(), delay: time.Second, reachability: reachability} for { - child, cancel := context.WithCancel(ctx) - done := make(chan error, 1) - activity := make(chan struct{}, 1) - deliveries := make(chan taskWatchDelivery) - alive := func() { - select { - case activity <- struct{}{}: - default: - } - } - go func(cursor uint64) { - done <- watch(child, cursor, alive, func(seq uint64) error { - delivery := taskWatchDelivery{seq: seq, done: make(chan error, 1)} - select { - case deliveries <- delivery: - case <-child.Done(): - return child.Err() - } - select { - case err := <-delivery.done: - return err - case <-child.Done(): - return child.Err() - } - }) - }(from) - tick := clock.After(time.Second) var err error - watching: - for { - select { - case <-activity: - last = clock.Now() - delay = time.Second - if unreachable { - unreachable = false - reachability(false) - } - case delivery := <-deliveries: - var consumeErr error - if delivery.seq > from { - consumeErr = consume(delivery.seq) - if consumeErr == nil { - from = delivery.seq - } - } - delivery.done <- consumeErr - if consumeErr != nil { - cancel() - <-done - return consumeErr - } - case err = <-done: - break watching - case <-ctx.Done(): - cancel() - <-done - return ctx.Err() - case <-tick: - notice() - tick = clock.After(time.Second) - } + var retry bool + from, err, retry = state.follow(ctx, from, watch, consume) + if !retry { + return err } - cancel() if err == nil || errors.Is(err, ErrTaskWatchEnded) { return nil } if ctx.Err() != nil { return ctx.Err() } - wait := delay - if unreachable { - wait = TaskComputerRetryEvery + if err := state.wait(ctx); err != nil { + return err } - retry := clock.After(wait) - tick = clock.After(time.Second) - waiting: - for { - select { - case <-ctx.Done(): - return ctx.Err() - case <-retry: - break waiting - case <-tick: - notice() - tick = clock.After(time.Second) - } + } +} + +type taskWatchState struct { + clock TaskWatchClock + last time.Time + delay time.Duration + unreachable bool + reachability func(bool) +} + +// notice and alive keep reachability on the caller's goroutine, including retry waits. +func (s *taskWatchState) notice() { + if !s.unreachable && s.clock.Now().Sub(s.last) >= TaskComputerSilentAfter { + s.unreachable = true + s.reachability(true) + } +} +func (s *taskWatchState) alive() { + s.last, s.delay = s.clock.Now(), time.Second + if s.unreachable { + s.unreachable = false + s.reachability(false) + } +} + +// follow joins this connection before retrying, so two watches cannot interleave callbacks. +func (s *taskWatchState) follow(ctx context.Context, from uint64, watch TaskWatch, consume func(uint64) error) (uint64, error, bool) { + child, cancel := context.WithCancel(ctx) + done := make(chan error, 1) + activity := make(chan struct{}, 1) + deliveries := make(chan taskWatchDelivery) + joined := false + defer func() { + cancel() + if !joined { + <-done + } + }() + alive := func() { + select { + case activity <- struct{}{}: + default: } - if delay < TaskComputerRetryEvery { - delay *= 2 - if delay > TaskComputerRetryEvery { - delay = TaskComputerRetryEvery + } + go func(cursor uint64) { + done <- watch(child, cursor, alive, func(seq uint64) error { return deliverTaskWatch(child, deliveries, seq) }) + }(from) + tick := s.clock.After(time.Second) + for { + select { + case <-activity: + s.alive() + case delivery := <-deliveries: + var err error + if delivery.seq > from { + err = consume(delivery.seq) + if err == nil { + from = delivery.seq + } + } + delivery.done <- err + if err != nil { + return from, err, false } + case err := <-done: + joined = true + return from, err, true + case <-ctx.Done(): + return from, ctx.Err(), false + case <-tick: + s.notice() + tick = s.clock.After(time.Second) + } + } +} + +// deliverTaskWatch waits for the durable consumer before allowing another wire event. +func deliverTaskWatch(ctx context.Context, deliveries chan<- taskWatchDelivery, seq uint64) error { + delivery := taskWatchDelivery{seq: seq, done: make(chan error, 1)} + select { + case deliveries <- delivery: + case <-ctx.Done(): + return ctx.Err() + } + select { + case err := <-delivery.done: + return err + case <-ctx.Done(): + return ctx.Err() + } +} + +// wait keeps the same silence notice active while backing off between connections. +func (s *taskWatchState) wait(ctx context.Context) error { + delay := s.delay + if s.unreachable { + delay = TaskComputerRetryEvery + } + retry, tick := s.clock.After(delay), s.clock.After(time.Second) +waiting: + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-retry: + break waiting + case <-tick: + s.notice() + tick = s.clock.After(time.Second) + } + } + if s.delay < TaskComputerRetryEvery { + s.delay *= 2 + if s.delay > TaskComputerRetryEvery { + s.delay = TaskComputerRetryEvery } } + return nil } // ErrTaskWatchEnded distinguishes a final frame from a transport drop. diff --git a/internal/session/task_reopen_r8_test.go b/internal/session/task_reopen_r8_test.go new file mode 100644 index 0000000000..9d55368205 --- /dev/null +++ b/internal/session/task_reopen_r8_test.go @@ -0,0 +1,90 @@ +package session + +import ( + "context" + "sync/atomic" + "testing" + "time" +) + +// R8 recovery splits must retain authoritative abandonment on the local fallthrough path. +func TestReopeningKeepsTheAbandonmentSavedBeforeItsCheckpoint(t *testing.T) { + taskComputerChoiceHome(t) + t.Setenv("CODEAF_TASK_BELT", "bash") + local := newBeltRunDouble("done here") + local.real = true + local.honoursStop = true + registerBeltRunEngine(t, local) + ready := make(chan struct{}) + var starts, resumes atomic.Int32 + remote := &scriptedTaskEngine{ + start: func(ctx context.Context, spec RunSpec) RunSummary { + starts.Add(1) + r := remoteRecordFor(spec) + if err := spec.SaveRemote(r, true); err != nil { + t.Error(err) + } + close(ready) + <-ctx.Done() + return RunSummary{Outcome: "interrupted", KeepCopy: true} + }, + resume: func(ctx context.Context, spec RunSpec) RunSummary { + resumes.Add(1) + return RunSummary{Outcome: "interrupted", KeepCopy: true} + }, + } + place, repo, profile := t.TempDir(), beltRunCommittedRepo(t), t.TempDir() + open := func() *Agent { + a, _ := newTestAgent(t, beltRunCompleter{text: "done"}, func(c *Config) { + c.Workspace = repo + c.Place = Place{Dir: place} + c.SessionFile = c.Place.Transcript() + c.ProfileDir = profile + c.TaskComputers = testTaskComputers{engine: remote} + }) + return a + } + first := open() + if _, _, _, err := first.StartTaskOn(t.Context(), "Repair export", false, "work computer"); err != nil { + t.Fatal(err) + } + <-ready + _ = first.Close() + waitRemoteRun(t, first) + store, err := openRemoteTaskPlan(first.graph(), 1) + if err != nil { + t.Fatal(err) + } + row, _ := runRowOf(first.graph(), 1) + record := readRemoteRecord(store, row.Remote) + record.Abandoned = true + record.AckPending = false + if err := writeRemoteRecord(store, *record); err != nil { + t.Fatal(err) + } + if err := store.CompleteRoot("the local replacement finished"); err != nil { + t.Fatal(err) + } + _ = store.Close() + second := open() + events, stop := second.WatchTaskUpdates() + defer stop() + deadline := time.After(5 * time.Second) + for { + select { + case event := <-events: + if event.Kind != EventTaskUpdate || event.Task == nil || event.Task.ID != 1 { + continue + } + if event.Task.Remote == nil || !event.Task.Remote.Abandoned { + t.Fatalf("reopen forgot the saved abandonment: %+v", event.Task.Remote) + } + if starts.Load() != 1 || resumes.Load() != 0 || local.didRun() { + t.Fatal("settled recovery started another worker") + } + return + case <-deadline: + t.Fatal("reopened task was not replayed") + } + } +} diff --git a/internal/session/task_run_belt.go b/internal/session/task_run_belt.go index 082047bb6b..3dd3d48c49 100644 --- a/internal/session/task_run_belt.go +++ b/internal/session/task_run_belt.go @@ -606,23 +606,9 @@ func (a *Agent) startOrJoinTaskRunVia(ctx context.Context, id uint64, title, bri return false, err } } - placement := taskPlacementOf(ctx) - if _, resolved := ctx.Value(taskPlacementKey{}).(taskPlacement); !resolved { - placement, err = a.resolveTaskComputer(ctx, "", stand, "", via != nil) - if err != nil { - return false, err - } - ctx = withTaskPlacement(ctx, placement) - } - if placement.computer != nil { - remote, remoteErr := a.config.TaskComputers.Engine(ctx, placement.computer.Device) - if remoteErr != nil { - return false, remoteErr - } - if remote == nil { - return false, errors.New("this computer cannot run that task elsewhere right now") - } - engine = remote + ctx, engine, err = a.placeTaskRunEngine(ctx, engine, stand, via != nil) + if err != nil { + return false, err } path := g.planPath() storeID := strconv.FormatUint(id, 10) @@ -958,20 +944,10 @@ func (a *Agent) joinOrWait(ctx context.Context, stand taskStand, id uint64, titl } over := live.over if !live.ending && !live.closing && !live.stopped { - // Remote attempts have a frozen instruction; the wire has no add-work verb. - // Refuse any join involving one instead of losing a joined obligation. - if live.remoteEngine != nil || taskPlacementOf(ctx).computer != nil { - a.beltMu.Unlock() - return nil, errRemotePlacementBusy - } - if refusal := programJoinRefusal(via, live); refusal != nil { + if refusal := taskRunJoinRefusal(ctx, stand, via, live); refusal != nil { a.beltMu.Unlock() return nil, refusal } - if !stand.sharesWorkspace(live) { - a.beltMu.Unlock() - return nil, standsElsewhereError{underway: live.ground, asked: canonicalPath(stand.dir)} - } _, err := live.store.AddMany([]plandb.TaskSpec{{ ID: storeID, ParentID: live.root, Title: title, Description: brief, Dependencies: dependencies, Model: crewWishOf(ctx).worker, }}) @@ -2018,19 +1994,11 @@ func (a *Agent) driveBeltRun(ctx context.Context, engine RunEngine, run *beltRun spec.OnCharge = fold.charge } spec = a.remoteBeltSpec(run, spec) - var summary RunSummary - if run.remoteResume { - summary = run.remoteEngine.Resume(ctx, spec) - } else { - summary = startRunWithTaskStorageProof(ctx, engine, spec) - } + summary := startPlacedBeltRun(ctx, engine, run, spec) if run.remoteEngine != nil { engine = chatRunEngine } - run.remoteMu.Lock() - abandoned := run.remote != nil && run.remote.Abandoned - run.remoteMu.Unlock() - if abandoned { + if run.remoteAbandoned() { a.releaseBeltRun(run) return } @@ -2043,21 +2011,9 @@ func (a *Agent) driveBeltRun(ctx context.Context, engine RunEngine, run *beltRun // to be over instead of joining it ([Agent.joinOrWait]). The run is cleared // off the Agent and its waiters released on every road out of here, which is // what the deferred release says once. - a.beltMu.Lock() - run.ended, run.spent = a.taskClockNow(), summary.USD - if run.remoteEngine != nil && summary.Outcome == "stopped" { - run.stopped = true - } - run.ending = true - closing := run.closing - a.beltMu.Unlock() + closing := a.markBeltRunEnding(run, summary) defer a.releaseBeltRun(run) - if !summary.KeepCopy && run.remoteEngine == nil { - defer a.parkBeltRunStorage(run, summary) - } - if run.remoteEngine != nil && !summary.KeepCopy { - defer a.ackRemoteRun(run) - } + defer a.finishBeltRunStorage(run, summary) // The final receipt closes any gap between the last live reading and every // ending, before the person-stop road and the ordinary landing road split. if run.remoteEngine == nil { @@ -2072,32 +2028,31 @@ func (a *Agent) driveBeltRun(ctx context.Context, engine RunEngine, run *beltRun if run.cut != nil { defer run.cut() } - if closing && run.remoteEngine != nil { - run.remoteMu.Lock() - finished := run.remote != nil && run.remote.Finished - run.remoteMu.Unlock() - if !finished { - return - } + if run.remoteWaitingOnClose(closing) { + return } if a.settleBeltStopIfNeeded(run, summary) { return } - - if closing && run.delegate == nil && summary.Outcome != beltRunOutcomeDone { - // THE CONVERSATION CLOSED UNDER THE RUN, AND THAT IS NOBODY'S ENDING. The - // run is not landed, its row is not settled and nothing is written on its - // record: it is work nothing is driving any more, every step of it is in - // its store, and the row read back tomorrow says so in the one word for - // it ([TaskInterrupted]). Landing it here put the work into the folder of - // a person who had closed the window on it, and settling the row said - // `failed` about work that had not failed. - // - // A PROGRAM'S RUN IS THE EXCEPTION: [Agent.cutBeltRun] already wrote its - // ending, nothing can carry it on, and its folder is finished on its own - // branch below — never merged into the person's. + // THE CONVERSATION CLOSED UNDER THE RUN, AND THAT IS NOBODY'S ENDING. The + // run is not landed, its row is not settled and nothing is written on its + // record: it is work nothing is driving any more, every step of it is in + // its store, and the row read back tomorrow says so in the one word for + // it ([TaskInterrupted]). Landing it here put the work into the folder of + // a person who had closed the window on it, and settling the row said + // `failed` about work that had not failed. + // + // A PROGRAM'S RUN IS THE EXCEPTION: [Agent.cutBeltRun] already wrote its + // ending, nothing can carry it on, and its folder is finished on its own + // branch below — never merged into the person's. + if beltRunLeavesUnlanded(closing, run, summary) { return } + a.completeBeltRun(ctx, engine, run, summary) +} + +// Completion keeps landing, the final summary and the outcome note in their original order. +func (a *Agent) completeBeltRun(ctx context.Context, engine RunEngine, run *beltRun, summary RunSummary) { var landing RunLanding if summary.KeepCopy { // Refusing a transfer leaves the copy ready, not the plan still running. @@ -2146,6 +2101,44 @@ func (a *Agent) driveBeltRun(ctx context.Context, engine RunEngine, run *beltRun a.settleTaskCrew(run.row, outcome, run.crew.taskSpent(summary.USD)) } +// Resuming a placed run only watches; ordinary starts retain their storage proof. +func startPlacedBeltRun(ctx context.Context, engine RunEngine, run *beltRun, spec RunSpec) RunSummary { + if run.remoteResume { + return run.remoteEngine.Resume(ctx, spec) + } + return startRunWithTaskStorageProof(ctx, engine, spec) +} + +// The engine's return is the end of work, before landing and its summary calls. +func (a *Agent) markBeltRunEnding(run *beltRun, summary RunSummary) bool { + a.beltMu.Lock() + defer a.beltMu.Unlock() + run.ended, run.spent = a.taskClockNow(), summary.USD + if run.remoteEngine != nil && summary.Outcome == "stopped" { + run.stopped = true + } + run.ending = true + return run.closing +} + +// Storage settlement remains between cancellation and releasing the run's waiting callers. +func (a *Agent) finishBeltRunStorage(run *beltRun, summary RunSummary) { + if summary.KeepCopy { + return + } + if run.remoteEngine == nil { + a.parkBeltRunStorage(run, summary) + } else { + a.ackRemoteRun(run) + } +} + +// Closing a conversation leaves unfinished ordinary work available for continuation. +// Programs keep their existing terminal receipt because their own invocation cannot resume. +func beltRunLeavesUnlanded(closing bool, run *beltRun, summary RunSummary) bool { + return closing && run.delegate == nil && summary.Outcome != beltRunOutcomeDone +} + // finishBeltRunLanding settles the isolated work before its receipt is published. func (a *Agent) finishBeltRunLanding(ctx context.Context, engine RunEngine, run *beltRun, summary RunSummary) RunLanding { var landing RunLanding @@ -2584,27 +2577,7 @@ func cancelledByRunEnding(store *plandb.Store, id uint64) bool { // the row and the conversation's note are built from, so the two cannot name // two different endings. func (a *Agent) beltRunNotice(run *beltRun, summary RunSummary, landing RunLanding) TaskNotice { - state := TaskDone - if summary.Outcome != beltRunOutcomeDone { - state = TaskFailed - } - outcome, result := runEndingWords(summary) - report := result - if summary.Outcome != beltRunOutcomeDone { - if summary.Program != nil { - // THE PROGRAM'S OWN SENTENCE LEADS, and its account follows: the - // reason line a surface draws is the report's first line. - report = strings.TrimSpace(outcome + "\n" + result) - } else if report == "" { - report = outcome - } - } - if line := beltLandingLine(landing); line != "" { - if report != "" { - report += "\n" - } - report += line - } + state, report := beltRunResultWords(summary, landing) if run.remoteEngine != nil && summary.NeedsDecision { state = TaskUnverified } @@ -2881,3 +2854,43 @@ func (tree taskTree) workerWorkspaceURI() string { func (stand taskStand) sharesWorkspace(live *beltRun) bool { return canonicalPath(stand.dir) == live.ground && sameWorkspaceSetRequest(stand.workspaceSet, live.stand.workspaceSet) } + +// The result's words are composed before executor and crew metadata are attached. +func beltRunResultWords(summary RunSummary, landing RunLanding) (TaskState, string) { + state := TaskDone + if summary.Outcome != beltRunOutcomeDone { + state = TaskFailed + } + outcome, result := runEndingWords(summary) + report := result + if summary.Outcome != beltRunOutcomeDone { + if summary.Program != nil { + // THE PROGRAM'S OWN SENTENCE LEADS, and its account follows: the + // reason line a surface draws is the report's first line. + report = strings.TrimSpace(outcome + "\n" + result) + } else if report == "" { + report = outcome + } + } + if line := beltLandingLine(landing); line != "" { + if report != "" { + report += "\n" + } + report += line + } + return state, report +} + +// A remote instruction is frozen, and a program or a different ground cannot join it. +func taskRunJoinRefusal(ctx context.Context, stand taskStand, via *delegate.Delegate, live *beltRun) error { + if live.remoteEngine != nil || taskPlacementOf(ctx).computer != nil { + return errRemotePlacementBusy + } + if refusal := programJoinRefusal(via, live); refusal != nil { + return refusal + } + if !stand.sharesWorkspace(live) { + return standsElsewhereError{underway: live.ground, asked: canonicalPath(stand.dir)} + } + return nil +} diff --git a/internal/session/task_run_continue.go b/internal/session/task_run_continue.go index 3ae66d285b..be2cd004a9 100644 --- a/internal/session/task_run_continue.go +++ b/internal/session/task_run_continue.go @@ -50,7 +50,7 @@ func (a *Agent) ContinueRun(ctx context.Context, row uint64) (string, error) { return "", errRunRoadUnavailable } - if kept, found := runRowOf(g, row); found && kept.Remote != nil && !kept.Remote.Abandoned { + if kept, remote := remoteRowToContinue(g, row); remote { return a.continueRemoteRun(ctx, kept) } // A RUN ALREADY GOING IS NOT CARRIED ON. Read under the start lock, held @@ -65,11 +65,8 @@ func (a *Agent) ContinueRun(ctx context.Context, row uint64) (string, error) { a.beltMu.Lock() live := a.beltRun a.beltMu.Unlock() - if live != nil { - if live.row == row { - return "", fmt.Errorf("%s is already running", taskStopName(row, live.title)) - } - return "", fmt.Errorf("%s is running, and a conversation drives one run at a time", taskStopName(row, live.title)) + if err := liveContinueRefusal(live, row); err != nil { + return "", err } kept, found := runRowOf(g, row) @@ -153,3 +150,20 @@ func runRowOf(g *TaskGraph, row uint64) (TaskNotice, bool) { } return TaskNotice{}, false } + +// Remote rows return to their saved executor before local continuation takes the start lock. +func remoteRowToContinue(g *TaskGraph, row uint64) (TaskNotice, bool) { + kept, found := runRowOf(g, row) + return kept, found && kept.Remote != nil && !kept.Remote.Abandoned +} + +// A live run is refused in the same words whether it is this row or another one. +func liveContinueRefusal(live *beltRun, row uint64) error { + if live == nil { + return nil + } + if live.row == row { + return fmt.Errorf("%s is already running", taskStopName(row, live.title)) + } + return fmt.Errorf("%s is running, and a conversation drives one run at a time", taskStopName(row, live.title)) +} diff --git a/internal/session/task_run_recover.go b/internal/session/task_run_recover.go index f99b496305..b07ae62b2d 100644 --- a/internal/session/task_run_recover.go +++ b/internal/session/task_run_recover.go @@ -39,40 +39,8 @@ func (a *Agent) recoverBeltRun() { return } kept, found := runRowOf(g, id) - if found { - remote := readRemoteRecord(store, kept.Remote) - if remote != nil { - kept.Remote = remote - - if remote.Settled && !remote.Abandoned { - return - } - if !remote.Abandoned { - if kept.PendingRun != nil && remote.Attempt == "" { - a.resumePendingRun(g, kept) - return - } - if remote.StartFailed { - kept.State = TaskFailed - a.publishRunRow(g, kept) - return - } - if kept.State.settled() && kept.State != TaskInterrupted && !kept.EndedAt.IsZero() { - // A persisted final row already completed landing; only its ack can - // remain after a crash between settlement and acknowledgement. - if remote.Finished && remote.Restored { - remote.Settled = true - remote.AckPending = true - _ = writeRemoteRecord(store, *remote) - kept.Remote = remote - - } - return - } - _, _ = a.continueRemoteRun(a.rootContext(), kept) - return - } - } + if a.recoverRemoteBeltRun(g, store, &kept, found) { + return } if !found || kept.State != TaskInterrupted { return @@ -289,3 +257,42 @@ func recoveredJoinedRows(g *TaskGraph, parent uint64) []uint64 { } return ids } + +// Remote recovery follows the authoritative sidecar and never starts another worker. +func (a *Agent) recoverRemoteBeltRun(g *TaskGraph, store *plandb.Store, kept *TaskNotice, found bool) bool { + if !found { + return false + } + remote := readRemoteRecord(store, kept.Remote) + if remote == nil { + return false + } + kept.Remote = remote + if remote.Settled && !remote.Abandoned { + return true + } + if remote.Abandoned { + return false + } + if kept.PendingRun != nil && remote.Attempt == "" { + a.resumePendingRun(g, *kept) + return true + } + if remote.StartFailed { + kept.State = TaskFailed + a.publishRunRow(g, *kept) + return true + } + if kept.State.settled() && kept.State != TaskInterrupted && !kept.EndedAt.IsZero() { + // A persisted final row already completed landing; only its acknowledgement can remain. + if remote.Finished && remote.Restored { + remote.Settled = true + remote.AckPending = true + _ = writeRemoteRecord(store, *remote) + kept.Remote = remote + } + return true + } + _, _ = a.continueRemoteRun(a.rootContext(), *kept) + return true +} diff --git a/internal/session/task_watch_r8_test.go b/internal/session/task_watch_r8_test.go new file mode 100644 index 0000000000..831a73fd01 --- /dev/null +++ b/internal/session/task_watch_r8_test.go @@ -0,0 +1,24 @@ +package session + +import ( + "context" + "errors" + "testing" +) + +// The CI watcher split must retain the fatal boundary for a failed durable consumer. +func TestAFailedRemoteRecordWriteStopsWatchingInsteadOfRetrying(t *testing.T) { + want := errors.New("the task record could not be written") + attempts := 0 + watch := func(ctx context.Context, from uint64, alive func(), deliver func(uint64) error) error { + attempts++ + if attempts > 1 { + return ErrTaskWatchEnded + } + return deliver(1) + } + err := FollowTaskWatch(t.Context(), nil, 0, watch, func(uint64) error { return want }, func(bool) {}) + if !errors.Is(err, want) || attempts != 1 { + t.Fatalf("failed record write returned %v after %d connections", err, attempts) + } +} diff --git a/internal/tui3/chordregistry_test.go b/internal/tui3/chordregistry_test.go index 52106be306..81a086a573 100644 --- a/internal/tui3/chordregistry_test.go +++ b/internal/tui3/chordregistry_test.go @@ -69,6 +69,7 @@ var chordRegistry = []chordClaim{ {"closeTabChord", []string{ctxTabs}}, {"completionToastKey", []string{ctxConversation}}, {"deviceKey", []string{ctxHome}}, + {"taskComputerCycleKey", []string{ctxQuestion}}, {"effortKey", []string{ctxBox}}, {"filesCopyKey", []string{ctxFiles}}, {"filesRevealKey", []string{ctxFiles}}, diff --git a/internal/tui3/taskcommand.go b/internal/tui3/taskcommand.go index 755383713e..f4a38426a0 100644 --- a/internal/tui3/taskcommand.go +++ b/internal/tui3/taskcommand.go @@ -236,14 +236,20 @@ func (a *app) runTaskCommand(arg string) tea.Cmd { } if computer != "" { solo = solo || config.TaskStartAt(a.profileDir) == config.TaskStartSingle - file := a.file - return a.startTaskDoorVia(brief, func(ctx context.Context) (uint64, string, string, error) { + file, ctx := a.file, a.ctx + conv := a.taskCopyDoorNotes(brief) + return a.offLoop(func() func(bool) tea.Cmd { + var id uint64 + var title, note string + var err error if bound, ok := onDoor.(interface { StartTaskOnIn(context.Context, string, bool, string, string, string) (uint64, string, string, error) }); ok { - return bound.StartTaskOnIn(ctx, brief, solo, computer, effort, file) + id, title, note, err = bound.StartTaskOnIn(ctx, brief, solo, computer, effort, file) + } else { + id, title, note, err = onDoor.StartTaskOn(ctx, brief, solo, computer, effort) } - return onDoor.StartTaskOn(ctx, brief, solo, computer, effort) + return foldTaskStarted(taskStartedMsg{kind: "single", id: strconv.FormatUint(id, 10), title: title, note: note, err: err, brief: brief, conv: conv}) }) } if effort != "" { From b3c5154d2bd89c605ca7b8c277eec25692b89124 Mon Sep 17 00:00:00 2001 From: Abir Abbas <abirabbas1998@gmail.com> Date: Sat, 31 Oct 2026 02:31:16 -0400 Subject: [PATCH 28/34] fix: a task sent to another computer answers before its copy is prepared Preparing the task copy scales with the repository and, on a loaded machine, took up to twenty seconds before the start returned, past the window's ten-second patience. A named remote task now publishes its queued row and answers at once through the existing deferred-start road, reuses the selected computer instead of reading the roster twice, and keeps stop, close, reopen and retained-copy ownership through preparation. A local task's start is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/session/stoplaw_test.go | 19 +- internal/session/task_elsewhere.go | 8 +- internal/session/task_remote_control.go | 10 +- internal/session/task_remote_record.go | 14 + internal/session/task_run_belt.go | 67 +++-- internal/session/task_run_recover.go | 56 +++- internal/session/task_start_r8b_test.go | 349 ++++++++++++++++++++++ internal/session/workspace_set_prepare.go | 10 + 8 files changed, 483 insertions(+), 50 deletions(-) create mode 100644 internal/session/task_start_r8b_test.go diff --git a/internal/session/stoplaw_test.go b/internal/session/stoplaw_test.go index d28a125a8e..a165a85aec 100644 --- a/internal/session/stoplaw_test.go +++ b/internal/session/stoplaw_test.go @@ -31,15 +31,16 @@ import ( // here because they are not built from a literal state: their notices copy the // node's state, and the graph is the owner `task:N` has always reached. var stoppableRowPublishers = map[string]struct{ kind, proof string }{ - "startOrJoinTaskRunVia": {CancelTask, "TestAStopOnARunsOwnRowEndsTheRun"}, - "startHeldBeltRun": {CancelTask, "TestHeldBeltRunStopsWithoutPreparingRepository"}, - "startAdmittedBeltRun": {CancelTask, "TestAStopOnARunsOwnRowEndsTheRun"}, - "preparePendingBeltRun": {CancelTask, "TestAStopOnARunsOwnRowEndsTheRun"}, - "ContinueRun": {CancelTask, "TestAStopReachesARunThatWasCarriedOn"}, - "newOrchestrateFamily": {CancelRun, "TestCancelStopsAnAdaptiveRun"}, - "sayForming": {CancelRun, "TestCancelStopsAnAdaptiveRun"}, - "pauseRun": {CancelRun, "TestCancelStopsAnAdaptiveRun"}, - "rename": {CancelRun, "TestCancelStopsAnAdaptiveRun"}, + "startOrJoinTaskRunVia": {CancelTask, "TestAStopOnARunsOwnRowEndsTheRun"}, + "startDeferredBeltRun": {CancelTask, "TestANamedTaskCanBeStoppedWhileItsCopyIsPreparing"}, + "startAdmittedBeltRun": {CancelTask, "TestAStopOnARunsOwnRowEndsTheRun"}, + "finishPendingCopy": {CancelTask, "TestStoppingARemoteTaskReturnsItsPartialChangeAndKeepsItsBranch"}, + "rememberPendingRemoteCopy": {CancelTask, "TestStoppingARemoteTaskReturnsItsPartialChangeAndKeepsItsBranch"}, + "ContinueRun": {CancelTask, "TestAStopReachesARunThatWasCarriedOn"}, + "newOrchestrateFamily": {CancelRun, "TestCancelStopsAnAdaptiveRun"}, + "sayForming": {CancelRun, "TestCancelStopsAnAdaptiveRun"}, + "pauseRun": {CancelRun, "TestCancelStopsAnAdaptiveRun"}, + "rename": {CancelRun, "TestCancelStopsAnAdaptiveRun"}, } // stoppableStates are the states a stop means something in, by the names this diff --git a/internal/session/task_elsewhere.go b/internal/session/task_elsewhere.go index fcbe71befa..36b2adcbe2 100644 --- a/internal/session/task_elsewhere.go +++ b/internal/session/task_elsewhere.go @@ -46,8 +46,8 @@ type TaskRunEngine interface { CheckCopy(context.Context, string, string) error } -// TaskComputerRecovery builds a watcher from an already admitted identity, -// even while the directory is unreachable. Network checks belong in the watch. +// TaskComputerRecovery builds an engine from a selected or saved identity. +// Selection already read the roster; each eventual dial checks permission again. type TaskComputerRecovery interface { RecoverEngine(TaskExecutor) TaskRunEngine } @@ -260,6 +260,8 @@ func (a *Agent) StartTaskOn(ctx context.Context, brief string, solo bool, comput // conversation cannot honor, even though the agent has the method shapes. func (a *Agent) TaskComputersSupported() bool { return a.config.TaskComputers != nil } +const taskComputersUnavailable = "paired task computers are unavailable here" + var errRemotePlacementBusy = errors.New("the work already underway uses another computer; let it finish before starting this task") // cloneTaskRemote keeps published rows immutable while the watcher advances. @@ -308,7 +310,7 @@ func (a *Agent) placeTaskRunEngine(ctx context.Context, engine RunEngine, stand if placement.computer == nil { return ctx, engine, nil } - remote, err := a.config.TaskComputers.Engine(ctx, placement.computer.Device) + remote, err := a.taskExecutorEngine(ctx, TaskExecutor{Device: placement.computer.Device, Name: placement.computer.Name}) if err != nil { return ctx, nil, err } diff --git a/internal/session/task_remote_control.go b/internal/session/task_remote_control.go index ac524e2c8f..b73a8c231e 100644 --- a/internal/session/task_remote_control.go +++ b/internal/session/task_remote_control.go @@ -17,7 +17,7 @@ import ( // is asked to resume it and no start can be issued by this road. func (a *Agent) continueRemoteRun(ctx context.Context, kept TaskNotice) (string, error) { if a.config.TaskComputers == nil { - return "", errors.New("paired task computers are unavailable here") + return "", errors.New(taskComputersUnavailable) } a.lockBeltStart() defer a.beltStartMu.Unlock() @@ -240,6 +240,14 @@ func (a *Agent) startTaskHere(g *TaskGraph, r *taskHereReplacement) (string, err } func (a *Agent) stopRemoteRun(run *beltRun) { + // A pending task still owns only local preparation; no remote receipt is owed yet. + a.beltMu.Lock() + pending, cut := run.pending, run.cut + a.beltMu.Unlock() + if pending && cut != nil { + cut() + return + } run.remoteMu.Lock() record := cloneTaskRemote(run.remote) run.remoteMu.Unlock() diff --git a/internal/session/task_remote_record.go b/internal/session/task_remote_record.go index 9b933a3f2e..341e3baa44 100644 --- a/internal/session/task_remote_record.go +++ b/internal/session/task_remote_record.go @@ -396,3 +396,17 @@ func (run *beltRun) remoteWaitingOnClose(closing bool) bool { defer run.remoteMu.Unlock() return run.remote == nil || !run.remote.Finished } + +// Preparation receipts survive cancellation before dispatch, including retained partial roots. +func (a *Agent) rememberPendingRemoteCopy(run *beltRun, tree taskTree) { + if run.remoteEngine == nil || tree.dir == "" && tree.workspaceSet == nil { + return + } + a.beltMu.Lock() + run.tree = tree + a.beltMu.Unlock() + if g := a.graph(); g != nil { + a.publishRunRow(g, TaskNotice{ID: run.row, Title: run.title, State: TaskQueued, + Copy: runCopyOf(tree), WorkspaceSet: cloneWorkspaceSet(tree.workspaceSet)}) + } +} diff --git a/internal/session/task_run_belt.go b/internal/session/task_run_belt.go index 3dd3d48c49..9a12020fe8 100644 --- a/internal/session/task_run_belt.go +++ b/internal/session/task_run_belt.go @@ -389,9 +389,9 @@ type beltRun struct { title string brief string stand taskStand - // pending is true until the run's machine admission has passed. A pending - // run has a plan store but no folder or working copy, so stopping it cannot - // leave a repository lock or branch behind. + // pending lasts through admission and deferred copy preparation. The row + // already names its plan; remote copies are recorded as they become available + // so stopping or closing cannot lose their ownership handles. pending bool admission RunAdmission // workspace is the run's own copy, the directory every worker types in, and @@ -679,11 +679,11 @@ func (a *Agent) startOrJoinTaskRunVia(ctx context.Context, id uint64, title, bri // ADMISSION IS CHECKED BEFORE A FOLDER OR COPY IS TOUCHED. A held run seeds // only its plan store so stopping it cannot leave a repository claim behind; - // the admitted road below keeps the original synchronous refusal order. + // admitted local tasks below keep the original synchronous refusal order. admission := newRunAdmission(a.config.TaskMaxLoad, a.config.TaskMinFreeMB, a.config.ProfileDir, a.graph().lanes) held := admission != nil && !admission.MayStart() - if held { - return a.startHeldBeltRun(ctx, engine, g, path, storeID, id, title, brief, stand, question, via, asked, admission) + if held || taskPlacementOf(ctx).computer != nil { + return a.startDeferredBeltRun(ctx, engine, g, path, storeID, id, title, brief, stand, question, via, asked, admission, held) } return a.startAdmittedBeltRun(ctx, engine, g, path, storeID, id, title, brief, stand, question, via, asked, admission) @@ -770,10 +770,10 @@ func (a *Agent) startAdmittedBeltRun(ctx context.Context, engine RunEngine, g *T return false, nil } -// startHeldBeltRun seeds the only state a machine-held run may own: its plan -// store and queued row. Crew routing remains synchronous, while folder and -// copy preparation stay behind the driver's later admission. -func (a *Agent) startHeldBeltRun(ctx context.Context, engine RunEngine, g *TaskGraph, path, storeID string, id uint64, title, brief string, stand taskStand, question string, via *delegate.Delegate, asked []string, admission RunAdmission) (bool, error) { +// startDeferredBeltRun publishes a durable queued row before preparing its copy. +// Remote starts must answer without repository-sized work on the ordered road; +// machine-held local runs keep their existing deferred admission and crew routing. +func (a *Agent) startDeferredBeltRun(ctx context.Context, engine RunEngine, g *TaskGraph, path, storeID string, id uint64, title, brief string, stand taskStand, question string, via *delegate.Delegate, asked []string, admission RunAdmission, held bool) (bool, error) { var crew *taskCrew var err error if via == nil && taskPlacementOf(ctx).computer == nil { @@ -798,23 +798,29 @@ func (a *Agent) startHeldBeltRun(ctx context.Context, engine RunEngine, g *TaskG // saying stop, and until this cancel was kept nothing could say it (stoprun.go). runCtx, cut := context.WithCancel(ctx) born := a.taskClockNow() + waiting := "" + var machineHeld map[string]bool + if held { + waiting = waitingMachineBusy + machineHeld = map[string]bool{planStoreID(storeID): true} + } run := &beltRun{ plan: plan, store: store, root: store.RootID(), row: id, title: title, brief: brief, stand: stand, ground: canonicalPath(stand.dir), pending: true, admission: admission, - machineHeld: map[string]bool{planStoreID(storeID): true}, + machineHeld: machineHeld, cut: cut, born: born, over: make(chan struct{}), delegate: via, asked: asked, crew: crew, } run.wishedFrom(ctx) a.placeBeltRun(ctx, engine, run) a.installBeltRun(g, run) - // A HELD RUN IS QUEUED, not working: there is no worker and deliberately no - // copy yet. Its plan id is present from the first publish, which gives stop - // and recovery one persisted identity to reconcile. + // The queued row names its plan before any worker or copy exists, so stop + // and recovery already have one persisted identity to reconcile. A remote + // copy being prepared is not a machine-pressure refusal. a.publishRunRow(g, TaskNotice{ ID: id, Title: title, State: TaskQueued, Program: programName(via), Remote: cloneTaskRemote(run.remote), Computer: run.taskComputerName(), PlanTask: planStoreID(storeID), Crew: run.crewDecision(), CrewState: a.initialCrewRecord(run), - Model: run.crewWorker(), Waiting: waitingMachineBusy, + Model: run.crewWorker(), Waiting: waiting, PendingRun: &PendingRunRecord{WorkspaceSet: stand.workspaceSet, Brief: brief, Ground: stand.dir, Mode: stand.mode, Asked: asked}, }) go a.driveBeltRun(runCtx, engine, run, RunSpec{}) @@ -1816,6 +1822,10 @@ func (a *Agent) cutBeltRun() <-chan struct{} { stopped := false if run != nil { run.closing = true + // Local preparation must join before Close finishes its checkpoint. + if run.pending && run.remoteEngine != nil { + run.ending = true + } cut, stopped = run.cut, run.stopped } a.beltMu.Unlock() @@ -1882,10 +1892,12 @@ func (a *Agent) waitForBeltAdmission(ctx context.Context, run *beltRun) bool { } } -// preparePendingBeltRun is the only road from a held run to a repository. The -// admission check has passed before this function calls either folder or copy -// preparation, which is the lock-ownership seam for held tasks. +// preparePendingBeltRun readies a deferred run only after admission. A recovered +// remote copy is adopted before considering any new folder or copy preparation. func (a *Agent) preparePendingBeltRun(ctx context.Context, run *beltRun) error { + if run.remoteEngine != nil && run.tree.dir != "" { + return a.finishPendingCopy(ctx, run, run.folder, run.tree, run.ground) + } folder, err := a.readyRunFolder(run.row, run.title, filepath.Dir(run.store.Path()), run.stand, run.delegate, run.carry) if err != nil { return err @@ -1896,10 +1908,16 @@ func (a *Agent) preparePendingBeltRun(ctx context.Context, run *beltRun) error { tree = folder.tree() } else { tree, err = beltRunPrepare(ctx, a.config.Place, a.config.Workspace, a.journalID(), run.row, run.title, run.stand) + a.rememberPendingRemoteCopy(run, tree) if err != nil { return err } } + return a.finishPendingCopy(ctx, run, folder, tree, ground) +} + +// finishPendingCopy publishes the ready layout before the engine can dispatch work. +func (a *Agent) finishPendingCopy(ctx context.Context, run *beltRun, folder *ProgramFolder, tree taskTree, ground string) error { tree.bashBelt = true a.beltMu.Lock() stopped := run.stopped @@ -1920,12 +1938,11 @@ func (a *Agent) preparePendingBeltRun(ctx context.Context, run *beltRun) error { return nil } -// settlePendingBeltRun closes the two ways a held run can leave before its -// engine starts. A person stop uses the same stopped landing as an admitted -// run; a folder refusal keeps that refusal's words on both the store and row. +// settlePendingBeltRun records a stop or preparation refusal before dispatch. +// Closing a remote preparation keeps its intent for the next owner instead. func (a *Agent) settlePendingBeltRun(run *beltRun, err error) { a.beltMu.Lock() - stopped, why := run.stopped, run.stopReason + stopped, closing, why := run.stopped, run.closing, run.stopReason run.ending = true a.beltMu.Unlock() defer a.releaseBeltRun(run) @@ -1934,6 +1951,10 @@ func (a *Agent) settlePendingBeltRun(run *beltRun, err error) { a.settleTaskCrew(run.row, router.CrewStopped, 0) return } + // Closing before dispatch leaves the durable remote intent for reopen. + if closing && run.remoteEngine != nil { + return + } if err == nil { return } @@ -1948,7 +1969,7 @@ func (a *Agent) settlePendingBeltRun(run *beltRun, err error) { a.settleTaskCrew(run.row, router.CrewNotKept, 0) } -// startPendingBeltRun admits a held run and prepares the repository only after +// startPendingBeltRun admits a deferred run and prepares its copy only after // admission. It returns false after settling a stop, close, or preparation // refusal, so the driver has one short road for every pending exit. func (a *Agent) startPendingBeltRun(ctx context.Context, run *beltRun) (RunSpec, bool) { diff --git a/internal/session/task_run_recover.go b/internal/session/task_run_recover.go index b07ae62b2d..cf721ac646 100644 --- a/internal/session/task_run_recover.go +++ b/internal/session/task_run_recover.go @@ -2,6 +2,7 @@ package session import ( "context" + "errors" "log" "os" "path/filepath" @@ -175,19 +176,12 @@ func (a *Agent) resumePendingRun(g *TaskGraph, kept TaskNotice) { admission: newRunAdmission(a.config.TaskMaxLoad, a.config.TaskMinFreeMB, a.config.ProfileDir, g.lanes), machineHeld: map[string]bool{planStoreID(reopened.RootID()): true}, } - engine := chatRunEngine - if kept.Remote != nil && !kept.Remote.Abandoned && a.config.TaskComputers != nil { - remote, err := a.taskExecutorEngine(ctx, kept.Remote.Executor) - if err != nil { - cut() - reopened.Close() - a.interruptUnrecoverableRun(g, kept, err.Error()) - return - } - engine = remote - run.remoteEngine = remote - run.remote = cloneTaskRemote(kept.Remote) - run.placement.computer = &TaskComputer{Device: kept.Remote.Executor.Device, Name: kept.Remote.Executor.Name, CanTakeTasks: true} + engine, err := a.pendingRunEngine(ctx, run, kept) + if err != nil { + cut() + reopened.Close() + a.interruptUnrecoverableRun(g, kept, err.Error()) + return } a.installBeltRun(g, run) kept.State, kept.Waiting = TaskQueued, waitingMachineBusy @@ -275,7 +269,7 @@ func (a *Agent) recoverRemoteBeltRun(g *TaskGraph, store *plandb.Store, kept *Ta return false } if kept.PendingRun != nil && remote.Attempt == "" { - a.resumePendingRun(g, *kept) + a.resumeRemotePreparation(g, *kept) return true } if remote.StartFailed { @@ -296,3 +290,37 @@ func (a *Agent) recoverRemoteBeltRun(g *TaskGraph, store *plandb.Store, kept *Ta _, _ = a.continueRemoteRun(a.rootContext(), *kept) return true } + +// Missing capability leaves the remote intent readable and recoverable without local execution. +func (a *Agent) resumeRemotePreparation(g *TaskGraph, kept TaskNotice) { + if a.config.TaskComputers == nil { + kept.State, kept.Report = TaskInterrupted, taskComputersUnavailable + a.publishRunRow(g, kept) + return + } + a.resumePendingRun(g, kept) +} + +// Recovery adopts any recorded copy; preparing it again would overwrite retained work. +func (a *Agent) pendingRunEngine(ctx context.Context, run *beltRun, kept TaskNotice) (RunEngine, error) { + if kept.Remote == nil || kept.Remote.Abandoned { + return chatRunEngine, nil + } + if a.config.TaskComputers == nil { + return nil, errors.New(taskComputersUnavailable) + } + remote, err := a.taskExecutorEngine(ctx, kept.Remote.Executor) + if err != nil { + return nil, err + } + run.remoteEngine, run.remote = remote, cloneTaskRemote(kept.Remote) + run.placement.computer = &TaskComputer{Device: kept.Remote.Executor.Device, Name: kept.Remote.Executor.Name, CanTakeTasks: true} + if kept.Copy != nil || kept.WorkspaceSet != nil { + tree, err := reopenWorkspaceSet(kept.Copy, kept.WorkspaceSet, a.config.Place) + if err != nil { + return nil, err + } + run.tree, run.workspace, run.ground = tree, tree.dir, tree.ground + } + return remote, nil +} diff --git a/internal/session/task_start_r8b_test.go b/internal/session/task_start_r8b_test.go new file mode 100644 index 0000000000..3d33c90777 --- /dev/null +++ b/internal/session/task_start_r8b_test.go @@ -0,0 +1,349 @@ +package session + +import ( + "context" + "errors" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/plandb" +) + +// R8B and contracts 1–2 require the named task's receipt before repository-sized work. +func TestANamedTaskAnswersWhileItsCopyIsStillPreparing(t *testing.T) { + a, _, entered, release := gatedRemoteStart(t) + id := answerGatedRemoteStart(t, a, entered, release) + row, found := runRowOf(a.graph(), id) + if !found || row.State != TaskQueued || row.PendingRun == nil || row.Remote == nil || row.Remote.Executor.Name != "work computer" || row.Waiting != "" { + t.Fatalf("preparing task lost its queued placement: %+v", row) + } + release() + waitRemoteRun(t, a) + row, _ = runRowOf(a.graph(), id) + if row.State != TaskFailed || !strings.Contains(row.Report, "the fixture copy could not be prepared") { + t.Fatalf("preparation failure did not reach the task row: %+v", row) + } +} + +// R8B and contract 11 keep stop independent of preparation after the receipt. +func TestANamedTaskCanBeStoppedWhileItsCopyIsPreparing(t *testing.T) { + a, called, entered, release := gatedRemoteStart(t) + id := answerGatedRemoteStart(t, a, entered, release) + if _, err := a.Cancel(CancelTask + ":" + strconv.FormatUint(id, 10)); err != nil { + t.Fatal(err) + } + waitRemoteRun(t, a) + row, _ := runRowOf(a.graph(), id) + if !row.Stopped || row.State != TaskFailed || called.Load() != 0 { + t.Fatalf("stopped preparation reached a worker or lost its ending: row=%+v starts=%d", row, called.Load()) + } +} + +func gatedRemoteStart(t *testing.T) (*Agent, *atomic.Int32, <-chan struct{}, func()) { + t.Helper() + remoteStartHome(t) + entered, freed := make(chan struct{}), make(chan struct{}) + var once sync.Once + release := func() { once.Do(func() { close(freed) }) } + previous := beltRunPrepare + beltRunPrepare = func(ctx context.Context, _ Place, _, _ string, _ uint64, _ string, _ taskStand) (taskTree, error) { + close(entered) + select { + case <-freed: + return taskTree{}, errors.New("the fixture copy could not be prepared") + case <-ctx.Done(): + return taskTree{}, ctx.Err() + } + } + t.Cleanup(func() { beltRunPrepare = previous }) + called := &atomic.Int32{} + remote := &scriptedTaskEngine{start: func(context.Context, RunSpec) RunSummary { + called.Add(1) + return RunSummary{Outcome: "incomplete", KeepCopy: true} + }} + a, _ := remoteTaskAgent(t, remote, func(c *Config) { + c.ProfileDir = filepath.Join(os.Getenv("CODEAF_HOME"), "profile") + c.TaskMaxLoad, c.TaskMinFreeMB = 0, 0 + }) + t.Cleanup(func() { release(); joinRemoteStartFixture(t, a) }) + return a, called, entered, release +} + +func answerGatedRemoteStart(t *testing.T, a *Agent, entered <-chan struct{}, release func()) uint64 { + t.Helper() + type answer struct { + id uint64 + err error + } + reply := make(chan answer, 1) + returned := make(chan struct{}) + go func() { + defer close(returned) + id, _, _, err := a.StartTaskOn(t.Context(), "repair the export", true, "work computer") + reply <- answer{id, err} + }() + t.Cleanup(func() { release(); <-returned }) + select { + case <-entered: + case result := <-reply: + if result.err != nil || result.id == 0 { + t.Fatalf("named task was not accepted: %+v", result) + } + select { + case <-entered: + case <-time.After(30 * time.Second): + t.Fatal("accepted task never reached preparation") + } + return result.id + case <-time.After(30 * time.Second): + t.Fatal("named task never reached preparation") + } + select { + case result := <-reply: + if result.err != nil || result.id == 0 { + t.Fatalf("named task was not accepted: %+v", result) + } + return result.id + case <-time.After(5 * time.Second): + t.Fatal("the start receipt waited for repository preparation") + return 0 + } +} + +func remoteStartHome(t *testing.T) { + t.Helper() + home, err := os.MkdirTemp("/tmp", "rn-l1-r8b-") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.RemoveAll(home) }) + t.Setenv("HOME", home) + t.Setenv("CODEAF_HOME", home) +} + +type alreadySelectedTaskComputers struct { + testTaskComputers + factories, recoveries atomic.Int32 +} + +func (c *alreadySelectedTaskComputers) Engine(context.Context, string) (TaskRunEngine, error) { + c.factories.Add(1) + return nil, errors.New("the directory was queried again for a selected computer") +} +func (c *alreadySelectedTaskComputers) RecoverEngine(TaskExecutor) TaskRunEngine { + c.recoveries.Add(1) + return c.engine +} + +// R8B and contract 6 resolve once; each eventual dial still checks permission. +func TestASelectedTaskComputerDoesNotNeedAnotherRosterReadForItsEngine(t *testing.T) { + remoteStartHome(t) + remote := &scriptedTaskEngine{start: func(context.Context, RunSpec) RunSummary { + return RunSummary{Outcome: "incomplete", KeepCopy: true} + }} + computers := &alreadySelectedTaskComputers{testTaskComputers: testTaskComputers{engine: remote}} + a, _ := remoteTaskAgent(t, remote, func(c *Config) { + c.TaskComputers = computers + c.ProfileDir = filepath.Join(os.Getenv("CODEAF_HOME"), "profile") + }) + if _, _, _, err := a.StartTaskOn(t.Context(), "repair the export", true, "work computer"); err != nil { + t.Fatal(err) + } + waitRemoteRun(t, a) + if computers.factories.Load() != 0 || computers.recoveries.Load() != 1 { + t.Fatalf("selected engine read the roster again: factories=%d selected=%d", computers.factories.Load(), computers.recoveries.Load()) + } +} + +// R8B and contract 10 retain a not-yet-dispatched task across closing its chat. +func TestReopeningANamedTaskResumesItsPendingCopyBeforeDispatch(t *testing.T) { + first, started, entered, release := gatedRemoteStart(t) + id := answerGatedRemoteStart(t, first, entered, release) + if err := first.Close(); err != nil { + t.Fatal(err) + } + waitRemoteRun(t, first) + store, err := plandb.Open(first.graph().planPath(), "", "", "", "") + if err != nil { + t.Fatal(err) + } + root := store.Task(store.RootID()) + _ = store.Close() + if root == nil || terminalStoreStatus(root.Status) { + t.Fatalf("closing preparation ended the pending task: %+v", root) + } + // The old preparer has joined, so recovery can exercise the real copy road. + beltRunPrepare = prepareTaskTreeOn + second, _ := newTestAgent(t, beltRunCompleter{text: "done"}, func(c *Config) { + c.Workspace, c.Place, c.SessionFile = first.config.Workspace, first.config.Place, first.config.SessionFile + c.ProfileDir, c.TaskComputers = first.config.ProfileDir, first.config.TaskComputers + c.AskConsent = false + }) + waitRemoteRun(t, second) + row, found := runRowOf(second.graph(), id) + if !found || row.PendingRun != nil || row.Remote == nil || row.Remote.Executor.Name != "work computer" || started.Load() != 1 { + t.Fatalf("reopen did not dispatch the original placement once: row=%+v starts=%d", row, started.Load()) + } + second.recoverBeltRun() + if started.Load() != 1 { + t.Fatal("repeated recovery dispatched another task") + } +} + +// Contracts 10–11 require an unavailable executor to remain remote until an explicit replacement. +func TestReopeningAPendingRemoteTaskDoesNotChooseTheLocalEngine(t *testing.T) { + first, _, entered, release := gatedRemoteStart(t) + id := answerGatedRemoteStart(t, first, entered, release) + if err := first.Close(); err != nil { + t.Fatal(err) + } + waitRemoteRun(t, first) + first.SettleWrites() + // A mistaken local dispatch must fail the assertion rather than reuse the first gate. + beltRunPrepare = func(context.Context, Place, string, string, uint64, string, taskStand) (taskTree, error) { + return taskTree{}, errors.New("fixture caught an unexpected local dispatch") + } + second := reopenRemoteStart(t, first, nil) + second.beltMu.Lock() + dispatched := second.beltRun != nil + second.beltMu.Unlock() + row, found := runRowOf(second.graph(), id) + if dispatched || !found || row.Remote == nil || row.PendingRun == nil || !strings.Contains(row.Report, "paired task computers are unavailable here") { + t.Fatalf("unavailable remote intent was lost or dispatched locally: live=%v row=%+v", dispatched, row) + } +} + +// Contract 10 preserves the actual owned copy when Close wins after preparation finishes. +func TestReopeningARemoteTaskAdoptsTheCopyPreparedAsTheChatClosed(t *testing.T) { + remoteStartHome(t) + t.Setenv("CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP", "1") + prepared := make(chan taskTree, 1) + previous := beltRunPrepare + var preparations atomic.Int32 + beltRunPrepare = func(ctx context.Context, p Place, workspace, chat string, id uint64, title string, stand taskStand) (taskTree, error) { + preparations.Add(1) + tree, err := previous(ctx, p, workspace, chat, id, title, stand) + if err != nil { + return tree, err + } + if err := os.WriteFile(filepath.Join(tree.dir, "kept-before-close"), []byte("prepared copy"), 0600); err != nil { + return tree, err + } + prepared <- tree + <-ctx.Done() + return tree, nil + } + t.Cleanup(func() { beltRunPrepare = previous }) + var starts, resumes atomic.Int32 + remote := &scriptedTaskEngine{ + start: func(context.Context, RunSpec) RunSummary { + starts.Add(1) + return RunSummary{Outcome: "incomplete", KeepCopy: true} + }, + resume: func(context.Context, RunSpec) RunSummary { + resumes.Add(1) + return RunSummary{Outcome: "incomplete", KeepCopy: true} + }, + } + first, _ := remoteTaskAgent(t, remote, nil) + t.Cleanup(func() { joinRemoteStartFixture(t, first) }) + id, _, _, err := first.StartTaskOn(t.Context(), "repair export", true, "work computer") + if err != nil { + t.Fatal(err) + } + var copy taskTree + select { + case copy = <-prepared: + case <-time.After(30 * time.Second): + t.Fatal("owned copy never prepared") + } + if copy.workspaceSet == nil || copy.uri == "" { + t.Fatal("fixture did not prepare an owned workspace") + } + if err := first.Close(); err != nil { + t.Fatal(err) + } + waitRemoteRun(t, first) + first.SettleWrites() + beltRunPrepare = func(ctx context.Context, p Place, workspace, chat string, id uint64, title string, stand taskStand) (taskTree, error) { + preparations.Add(1) + return previous(ctx, p, workspace, chat, id, title, stand) + } + second := reopenRemoteStart(t, first, first.config.TaskComputers) + waitRemoteRun(t, second) + row, _ := runRowOf(second.graph(), id) + if row.Copy == nil || row.Copy.URI != copy.uri || preparations.Load() != 1 || starts.Load() != 1 || resumes.Load() != 0 { + t.Fatalf("reopen recarved or lost its owned copy: row=%+v preparations=%d starts=%d resumes=%d", row, preparations.Load(), starts.Load(), resumes.Load()) + } + if raw, err := os.ReadFile(filepath.Join(copy.dir, "kept-before-close")); err != nil || string(raw) != "prepared copy" { + t.Fatalf("prepared bytes lost: %q %v", raw, err) + } +} + +// Contract 3 keeps the ownership handles of a real copy returned with a preparation error. +func TestARemotePreparationFailureKeepsItsRetainedCopyOnTheTaskRow(t *testing.T) { + remoteStartHome(t) + t.Setenv("CODEAF_PROTOTYPE_WORKSPACE_OWNERSHIP", "1") + previous := beltRunPrepare + beltRunPrepare = func(ctx context.Context, p Place, workspace, chat string, id uint64, title string, stand taskStand) (taskTree, error) { + tree, err := previous(ctx, p, workspace, chat, id, title, stand) + if err != nil { + return tree, err + } + return tree, errors.New("fixture failure after retaining the private copy") + } + t.Cleanup(func() { beltRunPrepare = previous }) + remote := &scriptedTaskEngine{start: func(context.Context, RunSpec) RunSummary { + t.Error("failed preparation dispatched a worker") + return RunSummary{} + }} + a, _ := remoteTaskAgent(t, remote, nil) + t.Cleanup(func() { joinRemoteStartFixture(t, a) }) + id, _, _, err := a.StartTaskOn(t.Context(), "repair export", true, "work computer") + if err != nil { + t.Fatal(err) + } + waitRemoteRun(t, a) + row, _ := runRowOf(a.graph(), id) + if row.State != TaskFailed || row.Copy == nil || row.Copy.URI == "" || row.WorkspaceSet == nil || !strings.Contains(row.Report, "failure after retaining") { + t.Fatalf("failed row dropped its retained private copy: %+v", row) + } + if _, err := os.Stat(row.Copy.Dir); err != nil { + t.Fatal(err) + } +} + +func reopenRemoteStart(t *testing.T, first *Agent, computers TaskComputers) *Agent { + t.Helper() + second, _ := newTestAgent(t, beltRunCompleter{text: "done"}, func(c *Config) { + c.Workspace, c.Place, c.SessionFile = first.config.Workspace, first.config.Place, first.config.SessionFile + c.ProfileDir, c.TaskComputers = first.config.ProfileDir, computers + c.AskConsent = false + }) + t.Cleanup(func() { joinRemoteStartFixture(t, second) }) + return second +} + +func joinRemoteStartFixture(t *testing.T, a *Agent) { + t.Helper() + a.beltMu.Lock() + run := a.beltRun + a.beltMu.Unlock() + if run != nil && run.cut != nil { + run.cut() + } + _ = a.Close() + if run != nil { + select { + case <-run.over: + case <-time.After(30 * time.Second): + t.Error("fixture driver did not join before cleanup") + } + } +} diff --git a/internal/session/workspace_set_prepare.go b/internal/session/workspace_set_prepare.go index 21dac3cead..0b6d0fe336 100644 --- a/internal/session/workspace_set_prepare.go +++ b/internal/session/workspace_set_prepare.go @@ -537,6 +537,7 @@ func recoverWorkspaceSetPreparations(place Place, document taskDocument) (taskDo for i := range document.Runs { if document.Runs[i].ID == id { record.CrewState = document.Runs[i].CrewState + record = keepPendingRemotePreparation(receipt, record, document.Runs[i]) document.Runs[i] = record replaced = true break @@ -559,3 +560,12 @@ func recoverWorkspaceSetPreparations(place Place, document taskDocument) (taskDo } return document, changed } + +// A completed preparation receipt belongs to its saved remote intent, not a new local run. +func keepPendingRemotePreparation(receipt workspaceSetPreparation, recovered, saved runRecord) runRecord { + if receipt.Complete && saved.Remote != nil && !saved.Remote.Abandoned && saved.PendingRun != nil { + saved.Copy, saved.WorkspaceSet = recovered.Copy, recovered.WorkspaceSet + return saved + } + return recovered +} From d524a663a746a05030155e7f447af9a9c825be72 Mon Sep 17 00:00:00 2001 From: Abir Abbas <abirabbas1998@gmail.com> Date: Sat, 31 Oct 2026 02:31:16 -0400 Subject: [PATCH 29/34] docs: say a task sent elsewhere appears before its copy is ready Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../2085-task-on-another-computer.md | 32 ++++++------------- .../manual/chat/tasks-on-another-computer.md | 25 +++++++++++++++ internal/manual/chat_test.go | 2 ++ 3 files changed, 36 insertions(+), 23 deletions(-) diff --git a/docs/changes/unreleased/2085-task-on-another-computer.md b/docs/changes/unreleased/2085-task-on-another-computer.md index 50a40cbd10..82d8e70897 100644 --- a/docs/changes/unreleased/2085-task-on-another-computer.md +++ b/docs/changes/unreleased/2085-task-on-another-computer.md @@ -10,31 +10,17 @@ invalidates: - "A dev box only served `--at` chats. Its boot now passes `--take-tasks` when the box's installed build lists the flag, so a task can be sent to it." --- -The task's copy travels as an encrypted, content-addressed delta through the -relay, the other computer runs it with its own provider keys, and steps, phase -and spend stream back here; the change lands through the ordinary landing road, -or Review and Apply with private task copies on. A computer that stops -answering is shown as possibly still working with `run it here`; nothing re-runs -by itself, and an abandoned attempt's late result never lands. Only a task that -works in its own copy can go, both ends must be Linux or macOS, and a remote -task runs alone in its conversation. +The task's copy is prepared after the task appears, then travels as an +encrypted, content-addressed delta through the relay, the other computer runs it +with its own provider keys, and steps, phase and spend stream back here; the +change lands through the ordinary landing road, or Review and Apply with private +task copies on. A computer that stops answering is shown as possibly still +working with `run it here`; nothing re-runs by itself, and an abandoned +attempt's late result never lands. Only a task that works in its own copy can +go, both ends must be Linux or macOS, and a remote task runs alone in its +conversation. Everything is opt-in. With defaults left alone, pairing adds no relay traffic, and a local task's brief, records, the proposal card and the model's tool are byte-identical to before; the Run tasks on settings row is the only new thing a paired person sees. - -Acknowledged attempts retain bounded replay protection across serving restarts; -a removed computer also loses an existing task connection after the cached -permission expires. Result installation checks the moved original before -replacing it, and downloads leave the stop action available. Interrupted remote -work stays on the other computer so the original sent copy can run here. - -Mirrored records retain their original kinds, local fallback archives the remote -trajectory, and computers with the same name use the Devices labels. Ambiguous -names are refused with choices. Complete ledger entries missing a final newline -are deduplicated, and reopening inspects saved remote acknowledgements even when -the conversation checkpoint predates them. Unsupported conversations expose no -placement row through either settings tool. The manual includes exact refusal -sentences and recovery guidance; transfer timings are diagnostic rather than a -wall-clock test gate. diff --git a/internal/manual/chat/tasks-on-another-computer.md b/internal/manual/chat/tasks-on-another-computer.md index 8f119ac223..5012a7ffc3 100644 --- a/internal/manual/chat/tasks-on-another-computer.md +++ b/internal/manual/chat/tasks-on-another-computer.md @@ -337,3 +337,28 @@ follow that request. Update codeaf on both computers, check the chosen computer in Devices, and reopen the chat before retrying through its task controls. Keep the task copies while checking whether the original attempt started. A connection failure never grants permission to repeat an admitted attempt. + +## Why a task appears before its copy is ready + +A task sent to another computer appears here before its local copy is prepared. +Preparing and sending that copy happens in the background, so a large project +does not hold up the start receipt. You can stop the task while it prepares. + +If preparation fails, the task shows the reason instead of silently starting +local work. If the other computer is not serving, it still says +`<computer> is not taking tasks right now — run codeaf serve --take-tasks on it` +and offers `run it here`. Ordinary tasks on this computer keep their existing +start behavior. + +## Reopening a task while its copy is being prepared + +If you close the chat during preparation, reopening keeps the chosen computer +and adopts any copy already prepared. Retained partial copies stay named on +the failed task for inspection; they are never silently replaced. + +## Paired task computers are unavailable here + +`paired task computers are unavailable here` means this chat cannot currently +use its saved remote executor, for example after pairing becomes unavailable +or the home is made quiet. The pending task keeps its chosen computer for +when support returns; reopening never chooses local execution by itself. diff --git a/internal/manual/chat_test.go b/internal/manual/chat_test.go index 41e5210ee5..c23d1a3973 100644 --- a/internal/manual/chat_test.go +++ b/internal/manual/chat_test.go @@ -54,6 +54,8 @@ func TestTheChatManualAnswersTheQuestionsPeopleAsk(t *testing.T) { {"the task saved event is too large", "tasks-on-another-computer"}, {"this computer is already taking tasks", "tasks-on-another-computer"}, {"can a task run on my other computer", "tasks-on-another-computer"}, + {"why a task appears before its copy is ready", "tasks-on-another-computer"}, + {"paired task computers are unavailable here", "tasks-on-another-computer"}, {"run a task on my mac mini", "tasks-on-another-computer"}, {"what does running on mean", "tasks-on-another-computer"}, {"my other computer stopped answering", "tasks-on-another-computer"}, From a697e67bb581e56b0f1d0c084703b73c65ba6fee Mon Sep 17 00:00:00 2001 From: Abir Abbas <abirabbas1998@gmail.com> Date: Sat, 31 Oct 2026 03:08:15 -0400 Subject: [PATCH 30/34] fix: every list that shows a task sent elsewhere says where it ran MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Mac check found Background work's result row saying only `<task> · incomplete · open result` for a task that ran on another computer, and the tasks place blanked the computer in a window without task-computer controls. Placement is a fact about the record, so every surface now draws the existing running on / ran on words whenever the record names an executor. A local task's rows keep their exact bytes, pinned against dev's rendering. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../2085-task-on-another-computer.md | 4 +- internal/e2e/task_other_home_e2e_test.go | 6 +- .../manual/chat/tasks-on-another-computer.md | 16 +- internal/session/task_placement_row_test.go | 20 ++ internal/session/task_run_belt.go | 29 ++- internal/tui3/place_sessions.go | 2 +- internal/tui3/task.go | 11 +- internal/tui3/taskcomputer.go | 19 +- internal/tui3/taskcomputer_test.go | 10 +- internal/tui3/taskdone.go | 8 + internal/tui3/taskowner.go | 2 + internal/tui3/taskpane.go | 5 + internal/tui3/taskplacement_rows_test.go | 230 ++++++++++++++++++ internal/tui3/taskretry.go | 8 +- internal/tui3/tasksplace.go | 16 +- .../testdata/task_local_dev_9be6706b3.json | 41 ++++ internal/tui3/workcompletion.go | 11 +- 17 files changed, 395 insertions(+), 43 deletions(-) create mode 100644 internal/session/task_placement_row_test.go create mode 100644 internal/tui3/taskplacement_rows_test.go create mode 100644 internal/tui3/testdata/task_local_dev_9be6706b3.json diff --git a/docs/changes/unreleased/2085-task-on-another-computer.md b/docs/changes/unreleased/2085-task-on-another-computer.md index 82d8e70897..1ee6e1c815 100644 --- a/docs/changes/unreleased/2085-task-on-another-computer.md +++ b/docs/changes/unreleased/2085-task-on-another-computer.md @@ -18,7 +18,9 @@ task copies on. A computer that stops answering is shown as possibly still working with `run it here`; nothing re-runs by itself, and an abandoned attempt's late result never lands. Only a task that works in its own copy can go, both ends must be Linux or macOS, and a remote task runs alone in its -conversation. +conversation. Every list that shows the task says running on or ran on the +computer, and a task worker there sends no reasoning effort to a model whose +saved catalog row lists no reasoning. Everything is opt-in. With defaults left alone, pairing adds no relay traffic, and a local task's brief, records, the proposal card and the model's tool are diff --git a/internal/e2e/task_other_home_e2e_test.go b/internal/e2e/task_other_home_e2e_test.go index dc8e600985..efe094779a 100644 --- a/internal/e2e/task_other_home_e2e_test.go +++ b/internal/e2e/task_other_home_e2e_test.go @@ -70,7 +70,11 @@ func TestATaskRunsOnTheOtherHomeAndLandsHere(t *testing.T) { }) close(f.brainB.gate) f.waitDone(t, a, id) - f.waitNotice(t, a, id, func(n session.TaskNotice) bool { return n.Computer == "work computer" && n.RanElsewhere }) + // R9.1 requires the finished A-side reading to carry the placement words, + // rather than merely retaining an executor field that no row can draw. + f.waitNotice(t, a, id, func(n session.TaskNotice) bool { + return n.Computer == "work computer" && n.RanElsewhere && n.Doing == "ran on work computer" + }) t.Run("model_calls_reach_only_the_other_home", func(t *testing.T) { // Contract 1 distinguishes actual worker prompts from naming and chat calls. diff --git a/internal/manual/chat/tasks-on-another-computer.md b/internal/manual/chat/tasks-on-another-computer.md index 5012a7ffc3..686e25aa8b 100644 --- a/internal/manual/chat/tasks-on-another-computer.md +++ b/internal/manual/chat/tasks-on-another-computer.md @@ -58,7 +58,10 @@ On a task, `running on <computer>` means its worker runs on that paired computer while this chat stays here. Its steps appear here in order as it works. `ran on <computer>` records where completed work ran; `interrupted on <computer>` means the worker stopped without leaving an answer. -These words appear on task cards, the rail, Tasks rows and the task page. +These words appear on task cards and notice lines, the rail, Background work's +`open result` row, Tasks on Home and the task page. They stay with a saved task +even in a window opened before pairing or a window only watching the chat. +A task that ran here gains no computer words. The task's change returns to its copy here and follows normal landing. Private task copies wait for Review and Apply here. Conflicting changes are kept for @@ -70,6 +73,17 @@ your conversation and transcript here, and only sends the task's own copy. The remote worker uses that computer's provider keys; its spend is attributed to this chat and task on this computer too. +## Models without thinking on the other computer + +A task sent to another computer uses that computer's saved model catalog. +When the model's published supported parameters exclude reasoning, its task +worker sends no reasoning effort. The worker reads already available catalog +rows; it does not fetch a catalog to make this choice. If the model's supported +parameters are unknown, its existing effort behavior stays in place. + +This applies to tasks sent to that computer. Plain `codeaf do` still sends +its chosen effort. + ## My other computer stopped answering After about a minute without an answer, the task says diff --git a/internal/session/task_placement_row_test.go b/internal/session/task_placement_row_test.go new file mode 100644 index 0000000000..50142d4b2c --- /dev/null +++ b/internal/session/task_placement_row_test.go @@ -0,0 +1,20 @@ +package session + +import "testing" + +// R9.1 requires the finished A-side row to carry the placement words after +// the worker's phase lane has been replaced by its final landing notice. +func TestTheFinishedTaskRowKeepsTheExecutorWords(t *testing.T) { + a, _ := newTestAgent(t, &scriptedCompleter{}, nil) + for _, tc := range []struct { + name string + failed bool + want string + }{{"beta", false, "ran on beta"}, {"beta", true, ""}, {"", false, ""}} { + run := &beltRun{row: 7, title: "Create hello.txt", remoteEngine: &scriptedTaskEngine{}, remote: &TaskRemoteRecord{Executor: TaskExecutor{Name: tc.name}, Finished: true, StartFailed: tc.failed}} + notice := a.beltRunNotice(run, RunSummary{Outcome: beltRunOutcomeDone}, RunLanding{}) + if notice.Doing != tc.want { + t.Errorf("executor %q, failed %v: finished row lost its placement words: got %q, want %q", tc.name, tc.failed, notice.Doing, tc.want) + } + } +} diff --git a/internal/session/task_run_belt.go b/internal/session/task_run_belt.go index 9a12020fe8..d5789e5be6 100644 --- a/internal/session/task_run_belt.go +++ b/internal/session/task_run_belt.go @@ -2620,16 +2620,7 @@ func (a *Agent) beltRunNotice(run *beltRun, summary RunSummary, landing RunLandi Crew: run.crewDecision(), Model: run.crewWorker(), CostUSD: run.crew.taskSpent(summary.USD), } if run.remoteEngine != nil { - if summary.Outcome == "stopped" { - notice.Stopped = true - notice.Ending = TaskEndingStopped - } - run.remoteMu.Lock() - notice.Remote = cloneTaskRemote(run.remote) - notice.Computer = run.remote.Executor.Name - notice.RanElsewhere = !run.remote.StartFailed - run.remoteMu.Unlock() - notice.Model = summary.Model + run.fillRemoteLandingNotice(¬ice, summary) } if state == TaskFailed && run.crew != nil { decision := run.crew.stoppedIfCutOff() @@ -2654,6 +2645,24 @@ func (a *Agent) beltRunNotice(run *beltRun, summary RunSummary, landing RunLandi return notice } +// A remote landing keeps the executor with its final reading, since this +// notice replaces the worker's phase row. +func (run *beltRun) fillRemoteLandingNotice(notice *TaskNotice, summary RunSummary) { + if summary.Outcome == "stopped" { + notice.Stopped = true + notice.Ending = TaskEndingStopped + } + run.remoteMu.Lock() + notice.Remote = cloneTaskRemote(run.remote) + notice.Computer = run.remote.Executor.Name + notice.RanElsewhere = !run.remote.StartFailed + if notice.RanElsewhere && notice.Computer != "" && notice.State != TaskInterrupted { + notice.Doing = TaskRanOn(notice.Computer) + } + run.remoteMu.Unlock() + notice.Model = summary.Model +} + // beltRunEnding is the run row's ending: a limit its person set, or how the // program a delegated run was handed to ended it — a crash is the fault it is, // and every other ending of the program's own is [TaskEndingProgram], whose diff --git a/internal/tui3/place_sessions.go b/internal/tui3/place_sessions.go index f1fff97a20..e10b7a17bc 100644 --- a/internal/tui3/place_sessions.go +++ b/internal/tui3/place_sessions.go @@ -484,7 +484,7 @@ const taskSheetDays = 14 // that could ask it at paint time. func (a *app) taskSheetMine() tasksMine { rows := a.taskSheetOwnRows() - mine := tasksMine{row: a.taskSheetSelfRow(), tilde: a.tilde, rows: make([]tasksMineRow, 0, len(rows)), taskComputers: a.taskComputersAvailable()} + mine := tasksMine{row: a.taskSheetSelfRow(), tilde: a.tilde, rows: make([]tasksMineRow, 0, len(rows))} for _, entry := range rows { row := tasksMineRow{entry: entry, runs: a.recordRuns(&entry)} if node := a.taskSheetNodeFor(&entry); node != nil { diff --git a/internal/tui3/task.go b/internal/tui3/task.go index 8dd229369c..5592946760 100644 --- a/internal/tui3/task.go +++ b/internal/tui3/task.go @@ -153,7 +153,9 @@ func (c *taskCard) settled() bool { return c.verdict != "" } // elapsed clock counts on the frame tick instead of freezing at whatever the // last update happened to say. Everything else is the notice, kept. type taskNode struct { - computer string + computer string + // A later local replacement clears placement; an older silent node cannot. + computerKnown bool unreachable, ranElsewhere bool remote *session.TaskRemoteRecord // workTurn records the request that admitted this live work, independently of completion time. @@ -1949,6 +1951,10 @@ func (a *app) taskCardRows(card *taskCard, width int, sel bool) []string { } head := a.taskHead(card, width, sel) if card.settled() { + if words := a.taskComputerWords(a.tasks[card.id]); words != "" { + stem := a.blockStem() + return []string{head, a.pal.dim(stem + fit(words, max(1, width-ansi.StringWidth(stem)))), a.taskFoot(card, width)} + } return []string{head, a.taskFoot(card, width)} } stem := a.pal.ask(a.blockStem()) @@ -5337,8 +5343,7 @@ func (a *app) taskUpdate(ev session.Event) tea.Cmd { resumed = a.resumeRetriedRoom(node) } node.state = notice.State - node.computer, node.unreachable, node.ranElsewhere = notice.Computer, notice.Unreachable, notice.RanElsewhere - node.remote = notice.Remote + node.takeComputerNotice(notice) if len(notice.DependsOn) > 0 { node.dependsOn = notice.DependsOn } diff --git a/internal/tui3/taskcomputer.go b/internal/tui3/taskcomputer.go index 8f9b865650..0a32e45b4d 100644 --- a/internal/tui3/taskcomputer.go +++ b/internal/tui3/taskcomputer.go @@ -210,6 +210,14 @@ func (s *sheet) settingValueWord(row config.Setting) string { // Placement is a separate fact from the shared state reading. A checkpoint // saying running alone never supplies a computer's identity. +// Both local and watching pages apply the same placement history, so an empty +// later notice can clear a known remote attempt without erasing an older record. +func (node *taskNode) takeComputerNotice(notice *session.TaskNotice) { + node.computerKnown = node.computerKnown || node.computer != "" || notice.Computer != "" || notice.Remote != nil + node.computer, node.unreachable, node.ranElsewhere = notice.Computer, notice.Unreachable, notice.RanElsewhere + node.remote = notice.Remote +} + func taskComputerWords(name string, unreachable, ran bool, state session.TaskState) string { if name == "" { return "" @@ -229,8 +237,10 @@ func taskComputerWords(name string, unreachable, ran bool, state session.TaskSta return "" } +// Placement belongs to the record; a window without computer controls still +// says where the recorded work ran. func (a *app) taskComputerWords(node *taskNode) string { - if !a.taskComputersAvailable() || node == nil { + if node == nil { return "" } if node.remote != nil && node.remote.Abandoned { @@ -258,11 +268,10 @@ func taskDoingWords(node *taskNode) string { } func (a *app) taskEntryComputerWords(entry session.TaskIndexEntry) string { - if !a.taskComputersAvailable() { - return "" - } if node := a.taskSheetNodeFor(&entry); node != nil { - return a.taskComputerWords(node) + if words := a.taskComputerWords(node); words != "" || node.computerKnown || node.remote != nil && node.remote.Abandoned { + return words + } } return taskComputerWords(entry.Computer, entry.Unreachable, entry.RanElsewhere, session.TaskState(entry.Status)) } diff --git a/internal/tui3/taskcomputer_test.go b/internal/tui3/taskcomputer_test.go index ac8c38b8eb..af1986bc15 100644 --- a/internal/tui3/taskcomputer_test.go +++ b/internal/tui3/taskcomputer_test.go @@ -268,8 +268,8 @@ func TestTheProposalCardNamesItsExplicitOrDefaultComputer(t *testing.T) { } } -// TestNothingAboutComputersIsDrawnWhenNothingIsPaired covers the absence law: -// nil TaskComputers leaves the old command grammar and local surface intact. +// TestNothingAboutComputersIsDrawnWhenNothingIsPaired covers the absence law +// for controls and local work. R9 keeps recorded placement visible regardless. func TestNothingAboutComputersIsDrawnWhenNothingIsPaired(t *testing.T) { a, base, _ := taskApp(t) f := &surfaceTaskComputerAgent{taskFake: base} @@ -281,12 +281,12 @@ func TestNothingAboutComputersIsDrawnWhenNothingIsPaired(t *testing.T) { if cmd, took := a.taskKey(key("ctrl+d")); cmd != nil || took { t.Fatal("an unpaired proposal takes the computer key") } - // A saved remote record does not enable a capability this conversation lacks. + // A saved executor is a fact, and does not enable unavailable controls. entry := session.TaskIndexEntry{ID: "9", Title: "Earlier export", Status: string(session.TaskDone), Computer: "work computer", RanElsewhere: true} reading := readTasks(session.World{}, a.taskSheetMine(), session.LastDays(a.now(), 1), tasksSort{}, a.now(), a.now()) line := tasksLine{kind: tasksLineTask, item: &tasksItem{entry: entry}} - if text := plain(reading.paint([]tasksLine{line}, 0, 160, a.pal, false)); strings.Contains(text, "work computer") { - t.Fatalf("an unavailable placement leaked from an old record: %s", text) + if text := plain(reading.paint([]tasksLine{line}, 0, 160, a.pal, false)); !strings.Contains(text, "ran on work computer") { + t.Fatalf("a saved executor disappeared with the controls: %s", text) } a.task.verdict = "approved" drive(t, a, key("ctrl+,")) diff --git a/internal/tui3/taskdone.go b/internal/tui3/taskdone.go index a6658d49d0..7787c74d7e 100644 --- a/internal/tui3/taskdone.go +++ b/internal/tui3/taskdone.go @@ -22,6 +22,8 @@ import ( type taskDone struct { id uint64 ident taskIdent + // Placement survives the live node, just like the rest of this receipt. + placement string // program is the program the work was handed to, "" for codeaf's own. // Its ending is the conversation's to act on (session's program_outcome.go), // so its card says it ended and that the chat has the rest @@ -205,6 +207,7 @@ func (a *app) landedCard(node *taskNode) { landed = a.now() } card := &taskDone{ + placement: a.taskComputerWords(node), id: node.id, ident: node.ident, program: a.nodeProgram(node), @@ -472,6 +475,11 @@ func (a *app) doneRows(card *taskDone, width int, sel bool) []string { return nil } out := []string{a.doneHead(card, width, sel)} + if card.placement != "" { + for _, line := range wrap(card.placement, width-2) { + out = append(out, " "+a.pal.dim(line)) + } + } if card.attention != "" { for _, line := range wrap(card.attention, width-2) { out = append(out, " "+a.pal.warn(line)) diff --git a/internal/tui3/taskowner.go b/internal/tui3/taskowner.go index 9cb4051399..b6fcc04894 100644 --- a/internal/tui3/taskowner.go +++ b/internal/tui3/taskowner.go @@ -455,6 +455,7 @@ func (a *app) tookGuestNotice(msg taskGuestNoticeMsg) tea.Cmd { } guest.told = true if guest.node != nil { + guest.node.takeComputerNotice(notice) guest.node.stopped, guest.node.ending = notice.Stopped, notice.Ending live := taskLiveLines(notice) guest.node.doing, guest.node.mending, guest.node.waiting = live.doing, live.mending, live.waiting @@ -1086,6 +1087,7 @@ func taskGuestNode(item tasksItem) *taskNode { title = strings.TrimSpace(item.entry.Label) } return &taskNode{ + computer: item.entry.Computer, unreachable: item.entry.Unreachable, ranElsewhere: item.entry.RanElsewhere, id: id, ident: identFor(id), title: title, diff --git a/internal/tui3/taskpane.go b/internal/tui3/taskpane.go index 61ab4d7bfc..7300a70762 100644 --- a/internal/tui3/taskpane.go +++ b/internal/tui3/taskpane.go @@ -341,6 +341,11 @@ func (a *app) taskPaneRecord(item tasksItem, width int) []taskPaneRow { // width (taskrecord.go's [app.taskCardAwayRows] states it). if item.away { var away []string + if placement := a.taskEntryComputerWords(entry); placement != "" { + for _, row := range wrap(placement, width) { + away = append(away, pal.dim(row)) + } + } for _, wrapped := range wrap(taskAwayCardWhere(item.window), width) { away = append(away, pal.ink(wrapped)) } diff --git a/internal/tui3/taskplacement_rows_test.go b/internal/tui3/taskplacement_rows_test.go new file mode 100644 index 0000000000..f1fe4ad99a --- /dev/null +++ b/internal/tui3/taskplacement_rows_test.go @@ -0,0 +1,230 @@ +package tui3 + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/session" +) + +func placementRowFixture(t *testing.T, state session.TaskState, computer string) (*app, session.TaskIndexEntry) { + t.Helper() + a := completionFoldApp(t) + entry := session.TaskIndexEntry{ID: "7", SessionID: a.taskSheetSelfID(), Title: "Create hello.txt", Label: "Create hello.txt", Status: string(state), Computer: computer} + notice := session.TaskNotice{Computer: computer} + if computer != "" { + notice.Remote = &session.TaskRemoteRecord{Executor: session.TaskExecutor{Name: computer}} + } + if state == session.TaskDone || state == session.TaskFailed { + entry.EndedAt = a.now() + entry.RanElsewhere, notice.RanElsewhere = computer != "", computer != "" + if notice.Remote != nil { + notice.Remote.Finished, notice.Remote.Settled = true, true + } + } + a.taskUpdate(update(7, entry.Title, state, notice)) + return a, entry +} + +func placementRowReadings(a *app, entry session.TaskIndexEntry) map[string]string { + item := &tasksItem{entry: entry, runs: true} + reading := tasksReading{now: a.now()} + rows := []tasksLine{{kind: tasksLineTask, item: item}, {kind: tasksLineTail, item: item}} + readings := map[string]string{ + "Background work": a.completionRows([]workCompletion{{task: 7, title: entry.Title, state: entry.Status}}, 200)[0].text, + "conversation rail": strings.Join(a.railUnder(a.tasks[7], 180), "\n"), + "conversation phase": strings.Join(a.railDoing(a.tasks[7], 180), "\n"), + "task room": a.roomStateWord(a.tasks[7]), + "Tasks": reading.paint(rows, 0, 200, a.pal, false), + "compact Tasks": reading.paint(rows, 1, 70, a.pal, false), + "task page": strings.Join(a.taskCardBody(entry, 180), "\n"), + } + var preview []string + for _, row := range a.taskPaneRecord(*item, 100) { + preview = append(preview, row.text) + } + readings["task preview"] = strings.Join(preview, "\n") + if entry.Status == string(session.TaskRunning) { + r, watched := watchedPlacementReading(a, entry) + lines := []tasksLine{{kind: tasksLineTask, item: &watched}, {kind: tasksLineTail, item: &watched}} + readings["Home watched Tasks"] = r.paint(lines, 0, 180, a.pal, false) + readings["Home watched compact Tasks"] = r.paint(lines, 1, 70, a.pal, false) + } + approved := &taskCard{id: 7, title: entry.Title, verdict: "approved"} + readings["accepted conversation card"] = strings.Join(a.taskCardRows(approved, 200, false), "\n") + if at := a.doneEntryFor(7); at >= 0 { + card := *a.entries[at].done + card.quiet = false + readings["conversation landing"] = strings.Join(a.doneRows(&card, 200, false), "\n") + card.open = true + readings["open conversation landing"] = strings.Join(a.doneRows(&card, 200, false), "\n") + } + return readings +} + +// R9.1 requires placement on the actual result door, including failed work and +// restored receipts, even when this window cannot choose task computers. +func TestTheBackgroundWorkRowSaysWhereARemoteTaskRan(t *testing.T) { + for _, state := range []session.TaskState{session.TaskDone, session.TaskFailed} { + for _, saved := range []bool{false, true} { + a, entry := placementRowFixture(t, state, "beta") + if saved { + delete(a.tasks, 7) + a.comp.tasks = []session.TaskIndexEntry{entry} + } + a.openWorkfold(1) + a.touch() + found := false + for _, row := range a.visible(a.bodyWidth()) { + if row.hit == hitWorkCompletion && row.completion.task == 7 { + found = true + if text := plain(row.text); strings.Count(text, "ran on beta") != 1 || !strings.Contains(text, "open result") { + t.Errorf("state %s, saved %v: %s", state, saved, text) + } + a.openWorkCompletion(row.completion) + if a.taskSheet.detail.Computer != "beta" || !a.taskSheet.detail.RanElsewhere { + t.Errorf("open result dropped the executor: %+v", a.taskSheet.detail) + } + } + } + if !found { + t.Fatalf("state %s, saved %v: no Background work result row", state, saved) + } + } + } +} + +// R9.1 makes the saved executor authoritative on each list, independently of +// whether this particular window has acquired the pairing capability. +func TestEveryTaskSurfaceKeepsTheRecordedComputerWithoutTheCapability(t *testing.T) { + for _, state := range []session.TaskState{session.TaskRunning, session.TaskDone} { + a, entry := placementRowFixture(t, state, "beta") + want := "running on beta" + if entry.RanElsewhere { + want = "ran on beta" + } + for surface, text := range placementRowReadings(a, entry) { + t.Run(string(state)+"/"+surface, func(t *testing.T) { + if strings.Count(plain(text), want) != 1 { + t.Fatalf("recorded placement missing or repeated: %s", plain(text)) + } + }) + } + t.Run(string(state)+"/saved task page", func(t *testing.T) { + delete(a.tasks, 7) + if text := plain(strings.Join(a.taskCardBody(entry, 180), "\n")); !strings.Contains(text, want) { + t.Fatal(text) + } + }) + } +} + +// R9.1 treats the index's executor as a fact even when a window still has an +// older live node from before the placement update arrived. +func TestASavedTaskComputerSurvivesAnOlderLiveNode(t *testing.T) { + a, entry := placementRowFixture(t, session.TaskDone, "") + a.file = filepath.Join(a.homeRoot, "conversation", "session.jsonl") + entry.SessionID = a.taskSheetSelfID() + entry.Computer, entry.RanElsewhere = "beta", true + a.comp.tasks = []session.TaskIndexEntry{entry} + found := false + for _, row := range a.taskSheetOwnRows() { + if row.ID == entry.ID { + found = true + entry = row + } + } + if !found { + t.Fatal("the saved task disappeared from the actual Tasks reading") + } + if text := plain(strings.Join(a.taskCardBody(entry, 180), "\n")); !strings.Contains(text, "ran on beta") { + t.Fatal(text) + } +} + +// R9.1 includes Home's merge with another window's live presence: presence +// does not carry placement and must preserve it from the saved record. +func TestHomeKeepsARemoteExecutorWhenAnotherWindowOwnsTheTask(t *testing.T) { + a, entry := placementRowFixture(t, session.TaskRunning, "beta") + r, item := watchedPlacementReading(a, entry) + if !item.away { + t.Fatalf("expected the watched task, got %+v", item) + } + lines := []tasksLine{{kind: tasksLineTask, item: &item}, {kind: tasksLineTail, item: &item}} + for _, i := range []int{0, 1} { + if text := plain(r.paint(lines, i, 180, a.pal, false)); !strings.Contains(text, "running on beta") { + t.Errorf("watched row %d: %s", i, text) + } + } + var preview []string + for _, row := range a.taskPaneRecord(item, 100) { + preview = append(preview, plain(row.text)) + } + if text := strings.Join(preview, "\n"); !strings.Contains(text, "running on beta") { + t.Errorf("watched preview: %s", text) + } + if text := a.roomStateWord(taskGuestNode(item)); !strings.Contains(text, "running on beta") { + t.Errorf("watched task room: %s", text) + } +} + +// R9.1 also applies after a watcher receives the owner's finished notice; +// phase text alone cannot retain the executor on a settled task page. +func TestAWatchingTaskPageKeepsTheOwnersExecutorAfterFinishing(t *testing.T) { + a, door := guestLab(t) + door.watching() + enterAway(t, a) + for _, state := range []session.TaskState{session.TaskRunning, session.TaskDone} { + finished := state == session.TaskDone + ownerSays(t, a, session.Event{Kind: session.EventTaskUpdate, Task: &session.TaskNotice{ID: 7, State: state, Computer: "beta", RanElsewhere: finished, + Remote: &session.TaskRemoteRecord{Executor: session.TaskExecutor{Name: "beta"}, Finished: finished}}}) + want := "running on beta" + if finished { + want = "ran on beta" + } + if text := a.roomStateWord(a.roomNode()); !strings.Contains(text, want) { + t.Errorf("%s: %s", state, text) + } + if a.tasks[7].computer != "" { + t.Fatal("the owner's executor changed this conversation's local task") + } + } +} + +func watchedPlacementReading(a *app, entry session.TaskIndexEntry) (tasksReading, tasksItem) { + entry.SessionID = "watched conversation" + owner := session.SessionRow{ID: entry.SessionID} + owner.Tasks.Rows = []session.TaskIndexEntry{entry} + world := session.World{Projects: []session.Project{{Sessions: []session.SessionRow{owner}}}} + mine := tasksMine{away: []session.ElsewhereTask{{SessionID: entry.SessionID, Session: "another window", Task: session.PresenceTask{ID: entry.ID, Title: entry.Title, State: entry.Status}}}} + r := readTasks(world, mine, session.LastDays(a.now(), 1), tasksSort{}, a.now(), a.now()) + return r, r.items[0] +} + +// R9's stability rule compares the same local rows with dev 9be6706b3's +// renderers, captured in the fixture with their original bytes intact. +func TestALocalTaskRowIsUnchangedInEveryList(t *testing.T) { + raw, err := os.ReadFile("testdata/task_local_dev_9be6706b3.json") + if err != nil { + t.Fatal(err) + } + var want map[string]map[string]string + if err := json.Unmarshal(raw, &want); err != nil { + t.Fatal(err) + } + for state, surfaces := range want { + a, entry := placementRowFixture(t, session.TaskState(state), "") + got := placementRowReadings(a, entry) + if len(got) != len(surfaces) { + t.Fatalf("%s: dev fixture covers %d surfaces, current row has %d", state, len(surfaces), len(got)) + } + for surface, original := range surfaces { + if got[surface] != original { + t.Errorf("%s/%s changed dev's local row:\n got %q\nwant %q", state, surface, got[surface], original) + } + } + } +} diff --git a/internal/tui3/taskretry.go b/internal/tui3/taskretry.go index afc20460f4..afcf9f3b00 100644 --- a/internal/tui3/taskretry.go +++ b/internal/tui3/taskretry.go @@ -90,10 +90,10 @@ func (a *app) taskRetried(msg taskRetriedMsg) { // The live graph overrides a saved landing while preserving its transcript and identity. func (a *app) currentTaskEntry(entry session.TaskIndexEntry) session.TaskIndexEntry { node := a.taskSheetNodeFor(&entry) - if node != nil { - if node.computer != "" || entry.Computer != "" { - entry.Status, entry.Ending = string(node.state), node.ending - } + // An older node may predate the executor in the saved row. Only a named + // placement or an explicit local replacement can supersede that fact. + if node != nil && (node.computerKnown || node.computer != "" || node.remote != nil && node.remote.Abandoned) { + entry.Status, entry.Ending = string(node.state), node.ending entry.Computer, entry.Unreachable, entry.RanElsewhere = node.computer, node.unreachable, node.ranElsewhere } if node != nil && node.crew != nil { diff --git a/internal/tui3/tasksplace.go b/internal/tui3/tasksplace.go index 5bea8cd931..18fde9f257 100644 --- a/internal/tui3/tasksplace.go +++ b/internal/tui3/tasksplace.go @@ -135,8 +135,6 @@ func (i tasksItem) pick() bool { return true } // ([app.recordRuns], asked once in place_tasks.go), because a reading that could // ask the app a question would be a reading that could ask it at paint time. type tasksMine struct { - // The capability travels with the reading so old records cannot enable it. - taskComputers bool // row is this conversation, as a row of the world — what a row of this // project's index is labelled with when the world scan has never seen the // conversation that ran it. @@ -184,7 +182,6 @@ type tasksChatView struct { // tasksReading is everything drawing and routing need from one world reading. // The seen stamp travels with the snapshot across place adapters. type tasksReading struct { - taskComputers bool // layouts is optional scratch owned by one body's paint, never a reading // retained across input. Cursor, pane, and body readers share each width. layouts map[int][]tasksLine @@ -270,7 +267,7 @@ func tasksKeyOf(entry session.TaskIndexEntry) tasksKey { // seconds ago and are the only authority for work that has not landed. func readTasks(world session.World, mine tasksMine, win session.UsageWindow, by tasksSort, seen, now time.Time) tasksReading { by = tasksSort{back: by.back} - r := tasksReading{win: win.Normalized(), seen: seen, now: now, tilde: mine.tilde, order: by, taskComputers: mine.taskComputers} + r := tasksReading{win: win.Normalized(), seen: seen, now: now, tilde: mine.tilde, order: by} // THE ROWS THE RUN'S STORE ANSWERS FOR ARE TAKEN OUT FIRST, before anything // reads them: they are drawn as the store's own plan rows below, and this is // the one line that keeps the pair from being drawn twice or drawn as the @@ -367,7 +364,11 @@ func readTasks(world session.World, mine tasksMine, win session.UsageWindow, by // there is one: presence carries neither, and a row that dropped the // program would open a page with no badge over a program's work // ([taskGuestNode]). - entry.Parent, entry.Program = get(key).entry.Parent, get(key).entry.Program + saved := get(key).entry + entry.Parent, entry.Program = saved.Parent, saved.Program + // Presence knows which window owns the work, but the saved record is + // still the authority for its executor. + entry.Computer, entry.Unreachable, entry.RanElsewhere = saved.Computer, saved.Unreachable, saved.RanElsewhere put(key, tasksItem{ entry: entry, row: tasksRowFor(world, mine, entry), runs: true, away: true, window: task.Session, here: mine.here[strings.TrimSpace(task.SessionID)], @@ -1629,11 +1630,6 @@ func (r tasksReading) paint(lines []tasksLine, i, width int, pal palette, lit bo return "" } line, lead := lines[i], tasksBareLead - if !r.taskComputers && line.item != nil && line.item.entry.Computer != "" { - item := *line.item - item.entry.Computer, item.entry.Unreachable, item.entry.RanElsewhere = "", false, false - line.item = &item - } room := width - ansi.StringWidth(lead) if room < 1 { room = 1 diff --git a/internal/tui3/testdata/task_local_dev_9be6706b3.json b/internal/tui3/testdata/task_local_dev_9be6706b3.json new file mode 100644 index 0000000000..127ef54ecc --- /dev/null +++ b/internal/tui3/testdata/task_local_dev_9be6706b3.json @@ -0,0 +1,41 @@ +{ + "done": { + "Background work": " \u001b[38;5;252mCreate hello.txt\u001b[39m\u001b[38;5;243m · done\u001b[39m\u001b[38;5;243m · \u001b[39m\u001b[38;5;146mopen result\u001b[39m", + "Tasks": " \u001b[38;5;110m✓\u001b[39m \u001b[38;5;252mCreate hello.txt\u001b[39m \u001b[38;5;243mdone\u001b[39m ", + "accepted conversation card": "\u001b[38;5;243m╭─ ? \u001b[39m \u001b[38;5;243m ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\u001b[39m\n\u001b[38;5;243m╰─ \u001b[39m\u001b[38;5;243mapproved\u001b[39m", + "compact Tasks": " \u001b[38;5;243mdone · now\u001b[39m", + "conversation landing": "\u001b[38;5;110m✓\u001b[39m \u001b[38;5;243m◆\u001b[39m \u001b[38;5;252mCreate hello.txt\u001b[39m\u001b[38;5;243m · done · ctrl+o\u001b[39m", + "conversation phase": "", + "conversation rail": "", + "open conversation landing": "\u001b[38;5;110m✓\u001b[39m \u001b[38;5;243m◆\u001b[39m \u001b[38;5;252mCreate hello.txt\u001b[39m\u001b[38;5;243m · done\u001b[39m", + "task page": "\u001b[38;5;252mdone · landed moments ago\u001b[39m", + "task preview": "\u001b[1m\u001b[38;5;252mCreate hello.txt\u001b[39m\u001b[22m\n\u001b[38;5;110m✓\u001b[39m \u001b[38;5;243mdone\u001b[39m\n\n\u001b[38;5;243mmoments ago\u001b[39m\n\n\u001b[1m\u001b[38;5;186menter\u001b[39m\u001b[22m\u001b[38;5;186m open\u001b[39m", + "task room": "done" + }, + "failed": { + "Background work": " \u001b[38;5;252mCreate hello.txt\u001b[39m\u001b[38;5;243m · incomplete\u001b[39m\u001b[38;5;243m · \u001b[39m\u001b[38;5;146mopen result\u001b[39m", + "Tasks": " \u001b[38;5;173m✕\u001b[39m \u001b[38;5;252mCreate hello.txt\u001b[39m \u001b[38;5;243mincomplete\u001b[39m ", + "accepted conversation card": "\u001b[38;5;243m╭─ ? \u001b[39m \u001b[38;5;243m ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\u001b[39m\n\u001b[38;5;243m╰─ \u001b[39m\u001b[38;5;243mapproved\u001b[39m", + "compact Tasks": " \u001b[38;5;243mincomplete · a fault · now\u001b[39m", + "conversation landing": "\u001b[38;5;173m✕\u001b[39m \u001b[38;5;243m◆\u001b[39m \u001b[38;5;252mCreate hello.txt\u001b[39m\u001b[38;5;243m · incomplete · ctrl+o\u001b[39m", + "conversation phase": "", + "conversation rail": "\u001b[38;5;173mincomplete · a fault\u001b[39m", + "open conversation landing": "\u001b[38;5;173m✕\u001b[39m \u001b[38;5;243m◆\u001b[39m \u001b[38;5;252mCreate hello.txt\u001b[39m\u001b[38;5;243m · incomplete\u001b[39m\n\u001b[38;5;243m a fault\u001b[39m", + "task page": "\u001b[38;5;252mincomplete · stopped moments ago\u001b[39m", + "task preview": "\u001b[1m\u001b[38;5;252mCreate hello.txt\u001b[39m\u001b[22m\n\u001b[38;5;173m✕\u001b[39m \u001b[38;5;243mincomplete · a fault\u001b[39m\n\n\u001b[38;5;243mmoments ago\u001b[39m\n\n\u001b[1m\u001b[38;5;186menter\u001b[39m\u001b[22m\u001b[38;5;186m open\u001b[39m", + "task room": "incomplete" + }, + "running": { + "Background work": " \u001b[38;5;252mCreate hello.txt\u001b[39m\u001b[38;5;243m · running\u001b[39m\u001b[38;5;243m · \u001b[39m\u001b[38;5;146mopen result\u001b[39m", + "Home watched Tasks": " \u001b[38;5;146m◐\u001b[39m \u001b[38;5;252mCreate hello.txt\u001b[39m \u001b[38;5;243manother window\u001b[39m ", + "Home watched compact Tasks": " \u001b[38;5;243mworking · another window · another window\u001b[39m", + "Tasks": " \u001b[38;5;146m◐\u001b[39m \u001b[38;5;252mCreate hello.txt\u001b[39m \u001b[38;5;243mworking\u001b[39m ", + "accepted conversation card": "\u001b[38;5;243m╭─ ? \u001b[39m \u001b[38;5;243m ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\u001b[39m\n\u001b[38;5;243m╰─ \u001b[39m\u001b[38;5;243mapproved\u001b[39m", + "compact Tasks": " \u001b[38;5;243mworking\u001b[39m", + "conversation phase": "", + "conversation rail": "", + "task page": "\u001b[38;5;252mworking\u001b[39m", + "task preview": "\u001b[1m\u001b[38;5;252mCreate hello.txt\u001b[39m\u001b[22m\n\u001b[38;5;146m◐\u001b[39m \u001b[38;5;243mworking\u001b[39m\n\n\u001b[1m\u001b[38;5;186menter\u001b[39m\u001b[22m\u001b[38;5;186m open\u001b[39m", + "task room": "working" + } +} diff --git a/internal/tui3/workcompletion.go b/internal/tui3/workcompletion.go index 14f75767e1..b1738fe975 100644 --- a/internal/tui3/workcompletion.go +++ b/internal/tui3/workcompletion.go @@ -86,7 +86,7 @@ func (a *app) openWorkCompletion(c *workCompletion) tea.Cmd { } } if node := a.tasks[c.task]; node != nil { - return a.openTaskRecord(&session.TaskIndexEntry{ID: id, Title: node.label, Label: node.label, Status: string(node.state), SessionID: a.taskSheetSelfID(), Crew: node.crew, Cost: node.spent(), Model: node.model}) + return a.openTaskRecord(&session.TaskIndexEntry{ID: id, Title: node.label, Label: node.label, Status: string(node.state), SessionID: a.taskSheetSelfID(), Crew: node.crew, Cost: node.spent(), Model: node.model, Computer: node.computer, Unreachable: node.unreachable, RanElsewhere: node.ranElsewhere}) } } return nil @@ -141,12 +141,15 @@ func (a *app) completionRows(cs []workCompletion, width int) []row { word = session.ProjectTask(session.TaskFacts{State: session.TaskFailed}).Word } action := "" + placement := "" door := false if c.task != 0 { door = a.tasks[c.task] != nil + placement = a.taskComputerWords(a.tasks[c.task]) for _, saved := range a.comp.tasks { if saved.ID == strconv.FormatUint(c.task, 10) && a.taskSheetOwnsEntry(&saved) { door = true + placement = a.taskEntryComputerWords(saved) break } } @@ -165,11 +168,15 @@ func (a *app) completionRows(cs []workCompletion, width int) []row { hit = hitWorkCompletion } tail := railSep + word + if placement != "" { + tail += railSep + placement + } + facts := tail if action != "" { tail += railSep + action } name := fit(title, max(1, width-2-ansi.StringWidth(tail))) - line := " " + a.pal.ink(name) + a.pal.dim(railSep+word) + line := " " + a.pal.ink(name) + a.pal.dim(facts) if action != "" { line += a.pal.dim(railSep) + a.pal.accent(action) } From 3987b53cc1d51b1864f82648cac015e33fc3e806 Mon Sep 17 00:00:00 2001 From: Abir Abbas <abirabbas1998@gmail.com> Date: Sat, 31 Oct 2026 03:08:15 -0400 Subject: [PATCH 31/34] fix: a task child sends no reasoning effort to a model without reasoning `codeaf do` has no catalog on its settings, so the child that `codeaf serve --take-tasks` runs always sent an effort, and a model without thinking refused every task sent to that computer (`does not support thinking`). Only that child, reached through the hidden task-store flag, now reads the service's already saved catalog rows and omits effort the way chat does; it never waits for a catalog fetch. Plain `codeaf do` is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- cmd/codeaf/do.go | 17 ++++ cmd/codeaf/do_reasoning_test.go | 153 ++++++++++++++++++++++++++++++++ 2 files changed, 170 insertions(+) create mode 100644 cmd/codeaf/do_reasoning_test.go diff --git a/cmd/codeaf/do.go b/cmd/codeaf/do.go index 32e58b5a8d..734ee98b93 100644 --- a/cmd/codeaf/do.go +++ b/cmd/codeaf/do.go @@ -24,6 +24,7 @@ import ( "github.com/Agent-Field/agentfield/sdk/go/ai" "github.com/Agent-Field/codeaf/internal/calllog" + "github.com/Agent-Field/codeaf/internal/catalog" "github.com/Agent-Field/codeaf/internal/config" "github.com/Agent-Field/codeaf/internal/crewroute" "github.com/Agent-Field/codeaf/internal/ctxbudget" @@ -34,6 +35,7 @@ import ( "github.com/Agent-Field/codeaf/internal/head" homepkg "github.com/Agent-Field/codeaf/internal/home" "github.com/Agent-Field/codeaf/internal/lease" + "github.com/Agent-Field/codeaf/internal/modelsource" "github.com/Agent-Field/codeaf/internal/resident" "github.com/Agent-Field/codeaf/internal/revision" "github.com/Agent-Field/codeaf/internal/router" @@ -3764,6 +3766,21 @@ func runErrand(request doRequest, seats config.Seats) (outcome headlessOutcome, if newClient == nil { newClient = newLiveClient } + if request.runStore != "" { + // Placement opts this child into chat's published capability rules. + // Only existing rows are read: another service's cache cannot answer + // for this model, and a task must not wait for catalog discovery. + build := newClient + newClient = func(settings config.Config, model string) (*liveClient, error) { + service, _ := settings.Sources.OrDefault(settings.APIKey, settings.BaseURL).For(model) + if strings.EqualFold(service.Source.ID, modelsource.DefaultID) { + settings.Models = sharedCatalog(settings).SnapshotNow() + } else { + settings.Models = catalog.Recall(config.CatalogOptionsFor(service, settings.ProfileDir)) + } + return build(settings, model) + } + } completerFor = crewCompleters(settings, newClient) } // EVERY SEAT CALL IS PRICED BEFORE IT IS MADE (internal/session's diff --git a/cmd/codeaf/do_reasoning_test.go b/cmd/codeaf/do_reasoning_test.go new file mode 100644 index 0000000000..d2720a2073 --- /dev/null +++ b/cmd/codeaf/do_reasoning_test.go @@ -0,0 +1,153 @@ +package main + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/catalog" + "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/router" + "github.com/Agent-Field/codeaf/internal/taskdoor" +) + +// R9.2 exercises the real task child settings, worker and provider adapter. +// A fresh service-owned cache must suffice without another discovery request. +func TestATaskChildSendsNoEffortToAModelWithoutReasoning(t *testing.T) { + for _, direct := range []bool{false, true} { + t.Run(map[bool]string{false: "default service", true: "connected service"}[direct], func(t *testing.T) { + for _, present := range noReasoningDoRequests(t, true, direct) { + for _, key := range []string{"reasoning", "reasoning_effort", "thinking"} { + if raw, ok := present[key]; ok { + t.Errorf("task child sent unsupported %s: %s", key, raw) + } + } + } + }) + } +} + +// R9's stability rule leaves plain do's explicit effort on the wire, even for +// the same model and cached catalog used by the task-child regression above. +func TestPlainCodeafDoStillSendsItsEffort(t *testing.T) { + for _, present := range noReasoningDoRequests(t, false, false) { + var reasoning struct { + Effort string `json:"effort"` + } + if err := json.Unmarshal(present["reasoning"], &reasoning); err != nil || reasoning.Effort != "low" { + t.Errorf("plain do changed its low effort: %s (%v)", present["reasoning"], err) + } + } +} + +func noReasoningDoRequests(t *testing.T, taskChild, direct bool) []map[string]json.RawMessage { + t.Helper() + home := beltRunEnv(t) + t.Setenv("HOME", home) + model := "fixture/no-thinking" + var mu sync.Mutex + var calls []map[string]json.RawMessage + listings := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/models": + mu.Lock() + listings++ + mu.Unlock() + w.Write([]byte(`{"data":[{"id":"fixture/no-thinking","name":"No thinking","context_length":32768,"supported_parameters":["tools","max_tokens"],"pricing":{"prompt":"0.000001","completion":"0.000001"}},{"id":"no-thinking","supported_parameters":["tools","max_tokens"]}]}`)) + case "/chat/completions": + var body map[string]json.RawMessage + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Error(err) + } + mu.Lock() + calls = append(calls, body) + mu.Unlock() + // Stop after the first real worker call; tool execution and task outcome + // are independent of the request shape this endpoint is recording. + w.WriteHeader(http.StatusForbidden) + w.Write([]byte(`{"error":{"message":"fixture stops after recording the request","code":403}}`)) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(server.Close) + t.Setenv("CODEAF_BASE_URL", server.URL) + profile := config.ProfileDir() + options := catalog.Options{BaseURL: server.URL, APIKey: "test-key", Dir: profile} + if direct { + if err := config.WriteSources(profile, []config.PersistedSource{{ID: "custom-fixture", Written: "local", Key: "test-key", Address: server.URL}}); err != nil { + t.Fatal(err) + } + model = "local/no-thinking" + settings, err := config.Load() + if err != nil { + t.Fatal(err) + } + service, _ := settings.Sources.For(model) + options = config.CatalogOptionsFor(service, profile) + } + models := catalog.Load(context.Background(), options) + wireModel := model + if direct { + wireModel = "no-thinking" + } + if supported, known := models.SupportsParameter(wireModel, "reasoning"); supported || !known { + t.Fatal("fixture did not publish a non-reasoning model") + } + // An unrelated default cache deliberately disagrees about this bare id. + // The child must read the selected service's compartment, as chat does. + if direct { + if err := catalog.Remember(catalog.Options{BaseURL: server.URL, Dir: profile}, []catalog.Model{{ID: "no-thinking", Parameters: []string{"reasoning"}}}); err != nil { + t.Fatal(err) + } + } + // The provider panel owns a separate price cache. Prime it too, so any + // discovery during do itself is observable instead of hidden by the fixture. + router.LoadCatalog(profile, server.URL, "test-key", server.Client()) + before := listings + oldCatalog := sharedCatalog + sharedCatalog = newSharedCatalog() + t.Cleanup(func() { sharedCatalog = oldCatalog }) + var stdout, stderr strings.Builder + request := doRequest{task: "write out.txt", workspace: t.TempDir(), asJSON: true, model: model, planModel: model, checkModel: model, + timeout: 30 * time.Second, slots: bound(1), stdout: &stdout, stderr: &stderr} + if taskChild { + attempt := t.TempDir() + request.runStore = filepath.Join(attempt, "run") + raw, err := json.Marshal(taskdoor.Start{Effort: "low"}) + if err != nil { + t.Fatal(err) + } + if err = os.WriteFile(filepath.Join(attempt, "request.json"), raw, 0600); err != nil { + t.Fatal(err) + } + } + if err := doErrand(request); exitCodeOf(err) != 2 { + t.Fatalf("expected fixture refusal, got %v\n%s\n%s", err, &stdout, &stderr) + } + // The accessor already belongs to do startup. Closing it joins any existing + // warm-up before restoring the test seam and inspecting the request count. + settings, err := config.Load() + if err != nil { + t.Fatal(err) + } + sharedCatalog(settings).Close() + mu.Lock() + defer mu.Unlock() + if len(calls) != 1 { + t.Fatalf("provider calls=%d; stdout=%s stderr=%s", len(calls), &stdout, &stderr) + } + if listings != before { + t.Errorf("do fetched its catalog at startup: before %d, after %d", before, listings) + } + return calls +} From a6e79d7ad08954e89a107376785fcec2e6935cb0 Mon Sep 17 00:00:00 2001 From: Abir Abbas <abirabbas1998@gmail.com> Date: Sat, 31 Oct 2026 05:21:00 -0400 Subject: [PATCH 32/34] fix: the task rail and /history rows say which computer a task ran on The Mac check found `ran on <computer>` only on Background work's result row. The rail's headline row now reserves the whole placement phrase before the title (dropping it whole when the rail is too narrow), and a finished task's /history row keeps the computer its record named after the plan row replaces the record. A local task's rail and history rows keep their exact bytes. The start receipt still does not name the computer: its reply carries no resolved placement and a request can be redirected here, so the manual now lists exactly where the words appear, and no longer claims Home, which lists no tasks. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../manual/chat/tasks-on-another-computer.md | 8 +- internal/tui3/task.go | 11 ++ internal/tui3/taskplacement_r10_test.go | 110 ++++++++++++++++++ internal/tui3/tasksplace.go | 24 ++++ .../testdata/task_local_r10_4443a1750.json | 10 ++ 5 files changed, 160 insertions(+), 3 deletions(-) create mode 100644 internal/tui3/taskplacement_r10_test.go create mode 100644 internal/tui3/testdata/task_local_r10_4443a1750.json diff --git a/internal/manual/chat/tasks-on-another-computer.md b/internal/manual/chat/tasks-on-another-computer.md index 686e25aa8b..ae219577ff 100644 --- a/internal/manual/chat/tasks-on-another-computer.md +++ b/internal/manual/chat/tasks-on-another-computer.md @@ -58,9 +58,11 @@ On a task, `running on <computer>` means its worker runs on that paired computer while this chat stays here. Its steps appear here in order as it works. `ran on <computer>` records where completed work ran; `interrupted on <computer>` means the worker stopped without leaving an answer. -These words appear on task cards and notice lines, the rail, Background work's -`open result` row, Tasks on Home and the task page. They stay with a saved task -even in a window opened before pairing or a window only watching the chat. +These words appear on task cards and landing notices, the task rail, +Background work's `open result` row, `/history` task rows and the task page. +They stay with a saved task even in a window opened before pairing or a +window only watching the chat. The initial `single task … started` receipt +does not name the computer. Home has no task row. A task that ran here gains no computer words. The task's change returns to its copy here and follows normal landing. Private diff --git a/internal/tui3/task.go b/internal/tui3/task.go index 5592946760..1aaf055791 100644 --- a/internal/tui3/task.go +++ b/internal/tui3/task.go @@ -4291,6 +4291,14 @@ func (a *app) railEntryRow(e railEntry, width int) string { const indent = " " glyph := a.railTreeGlyph(node) room := width - len(indent) - 2 + // Placement is a recorded fact; its complete words get cells before the + // title or clock. A rail too narrow for the phrase leaves it off whole. + placement := a.taskComputerWords(node) + if cells := ansi.StringWidth(placement); placement != "" && room >= cells+2 { + room -= cells + 1 + } else { + placement = "" + } // A program's badge stays on its row when the side column is compact. // The time gives up cells first, then the badge shortens, while the title // keeps enough room to name the work. @@ -4309,6 +4317,9 @@ func (a *app) railEntryRow(e railEntry, width int) string { } title, w := fitWidth(node.title, room) line := indent + glyph + " " + a.railTitle(node, title) + a.pal.programAfter(wears) + if placement != "" { + line += a.pal.dim(" " + placement) + } if age != "" { line += strings.Repeat(" ", max(room-w, 0)+1) + a.pal.dim(age) } diff --git a/internal/tui3/taskplacement_r10_test.go b/internal/tui3/taskplacement_r10_test.go new file mode 100644 index 0000000000..8a0f8ccd0b --- /dev/null +++ b/internal/tui3/taskplacement_r10_test.go @@ -0,0 +1,110 @@ +package tui3 + +import ( + "encoding/json" + "fmt" + "os" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/session" + "github.com/charmbracelet/x/ansi" +) + +func r10RailFixture(t *testing.T, computer string) (*app, *taskNode) { + t.Helper() + a, _, _ := taskApp(t) + a.taskUpdate(update(7, "replace the whole content of notes.txt with the requested words", session.TaskRunning, session.TaskNotice{Computer: computer})) + node := a.tasks[7] + node.started, node.began = a.now().Add(-4*time.Minute), a.now().Add(-4*time.Minute) + return a, node +} + +// R10(b2) names the executor on the actual rail headline, giving the title's +// cells to placement first. A narrow rail may drop the whole phrase only. +func TestTheRailSaysWhichComputerATaskRunsOn(t *testing.T) { + a, node := r10RailFixture(t, "beta") + for width := 8; width <= 120; width++ { + line := plain(a.railEntryRow(railEntry{node: node, group: railRunning}, width)) + if ansi.StringWidth(line) > width { + t.Errorf("width %d spilled: %q", width, line) + } + if width >= 24 && !strings.Contains(line, "running on beta") { + t.Errorf("width %d spent placement cells on the title: %q", width, line) + } + if !strings.Contains(line, "running on beta") && strings.Contains(line, "running on") { + t.Errorf("width %d cut a placement word: %q", width, line) + } + } +} + +func r10HistoryFixture(t *testing.T, computer string, historical bool) (*app, tasksReading, tasksItem) { + t.Helper() + a, _, _ := taskApp(t) + owner := session.SessionRow{ID: "r10-conversation", Title: "export work"} + entry := session.TaskIndexEntry{ID: "7", SessionID: owner.ID, Title: "create hello.txt containing the word KIWI", Status: string(session.TaskDone), Computer: computer, RanElsewhere: computer != ""} + plan := session.PlanTaskRow{ID: "p-r10", Title: entry.Title, Status: "done", Steps: 2, Archived: true} + row := tasksMineRow{entry: entry, planTask: plan.ID} + if historical { + plan.HistoricalRun, plan.HistoricalSession = 7, owner.ID + row.planTask = "" + } + mine := tasksMine{row: owner, rows: []tasksMineRow{row}, plan: []session.PlanTaskRow{plan}} + reading := readTasks(session.World{}, mine, session.LastDays(a.now(), 1), tasksSort{}, a.now(), a.now()) + if len(reading.items) != 1 || reading.items[0].plan == nil { + t.Fatalf("not the actual history plan row: %+v", reading.items) + } + return a, reading, reading.items[0] +} + +// R10(b3) follows the same plan-over-record replacement /history uses, both +// while the store is held and after it becomes a retained run receipt. +func TestAFinishedHistoryRowSaysWhichComputerItRanOn(t *testing.T) { + for _, historical := range []bool{false, true} { + a, r, item := r10HistoryFixture(t, "beta", historical) + lines := []tasksLine{{kind: tasksLineTask, item: &item}, {kind: tasksLineTail, item: &item}} + for i := range lines { + text := plain(r.paint(lines, i, 160, a.pal, false)) + if !strings.Contains(text, "ran on beta") || !strings.Contains(text, "2 steps") { + t.Errorf("historical %v, line %d: %s", historical, i, text) + } + } + } +} + +func r10LocalRows(t *testing.T) map[string]string { + t.Helper() + a, node := r10RailFixture(t, "") + rows := map[string]string{} + for _, width := range []int{20, 40, 80, 160} { + rows[fmt.Sprintf("rail/%d", width)] = a.railEntryRow(railEntry{node: node, group: railRunning}, width) + } + for _, historical := range []bool{false, true} { + a, r, item := r10HistoryFixture(t, "", historical) + lines := []tasksLine{{kind: tasksLineTask, item: &item}, {kind: tasksLineTail, item: &item}} + for i := range lines { + rows[fmt.Sprintf("history/%v/%d", historical, i)] = r.paint(lines, i, 160, a.pal, false) + } + } + return rows +} + +// R10's stability rule pins the actual local rail and plan/history bytes from +// 4443a1750, before either remote-only renderer change. +func TestLocalRailAndHistoryRowsKeepTheirOriginalBytes(t *testing.T) { + raw, err := os.ReadFile("testdata/task_local_r10_4443a1750.json") + if err != nil { + t.Fatal(err) + } + var want map[string]string + if err = json.Unmarshal(raw, &want); err != nil { + t.Fatal(err) + } + got := r10LocalRows(t) + for surface, original := range want { + if got[surface] != original { + t.Errorf("%s changed: got %q, want %q", surface, got[surface], original) + } + } +} diff --git a/internal/tui3/tasksplace.go b/internal/tui3/tasksplace.go index 18fde9f257..073756bc48 100644 --- a/internal/tui3/tasksplace.go +++ b/internal/tui3/tasksplace.go @@ -32,6 +32,7 @@ package tui3 import ( "sort" + "strconv" "strings" "time" @@ -276,6 +277,21 @@ func readTasks(world session.World, mine tasksMine, win session.UsageWindow, by // the same set of rows the page does — a run's row left in the name set // would hide its own plan row. var replaced map[tasksKey]bool + // The plan replaces the roster row, not its recorded executor. Keep only + // named placements; ordinary local readings need no extra map. + var placements map[tasksKey]session.TaskIndexEntry + for i := range mine.rows { + row := &mine.rows[i] + if row.entry.Computer != "" { + if placements == nil { + placements = make(map[tasksKey]session.TaskIndexEntry) + } + placements[tasksKeyOf(row.entry)] = row.entry + if row.planTask != "" { + placements[tasksKey{session: row.entry.SessionID, id: row.planTask}] = row.entry + } + } + } mine.rows, replaced = planStoreDraws(mine.rows, mine.plan) // order keeps the pass stable: a map alone would re-order the page on every // frame it was rebuilt, and the sections below are drawn in the order the @@ -343,6 +359,14 @@ func readTasks(world session.World, mine tasksMine, win session.UsageWindow, by continue } item := planItem(task, mine.row.ID, kin) + if placements != nil { + key := tasksKeyOf(item.entry) + if task.HistoricalRun != 0 { + key = tasksKey{session: task.HistoricalSession, id: strconv.FormatUint(task.HistoricalRun, 10)} + } + saved := placements[key] + item.entry.Computer, item.entry.Unreachable, item.entry.RanElsewhere = saved.Computer, saved.Unreachable, saved.RanElsewhere + } item.row = tasksRowFor(world, mine, item.entry) put(tasksKeyOf(item.entry), item) } diff --git a/internal/tui3/testdata/task_local_r10_4443a1750.json b/internal/tui3/testdata/task_local_r10_4443a1750.json new file mode 100644 index 0000000000..de2e2cadb3 --- /dev/null +++ b/internal/tui3/testdata/task_local_r10_4443a1750.json @@ -0,0 +1,10 @@ +{ + "history/false/0": " \u001b[38;5;110m✓\u001b[39m \u001b[38;5;252mcreate hello.txt containing the word KIWI\u001b[39m \u001b[38;5;243mdone · 2 steps\u001b[39m ", + "history/false/1": " \u001b[38;5;243m2 steps · done\u001b[39m", + "history/true/0": " \u001b[38;5;110m✓\u001b[39m \u001b[38;5;252mcreate hello.txt containing the word KIWI\u001b[39m \u001b[38;5;243mdone · 2 steps\u001b[39m ", + "history/true/1": " \u001b[38;5;243m2 steps · done\u001b[39m", + "rail/160": " \u001b[38;5;146m⠋\u001b[39m \u001b[38;5;252mreplace the whole content of notes.txt with the requested words\u001b[39m \u001b[38;5;243m4m\u001b[39m", + "rail/20": " \u001b[38;5;146m⠋\u001b[39m \u001b[38;5;252mreplace the …\u001b[39m \u001b[38;5;243m4m\u001b[39m", + "rail/40": " \u001b[38;5;146m⠋\u001b[39m \u001b[38;5;252mreplace the whole content of not…\u001b[39m \u001b[38;5;243m4m\u001b[39m", + "rail/80": " \u001b[38;5;146m⠋\u001b[39m \u001b[38;5;252mreplace the whole content of notes.txt with the requested words\u001b[39m \u001b[38;5;243m4m\u001b[39m" +} From 7aed329e8aaacb9794c27a10c9965acc84a402ce Mon Sep 17 00:00:00 2001 From: Abir Abbas <abirabbas1998@gmail.com> Date: Sat, 31 Oct 2026 05:21:00 -0400 Subject: [PATCH 33/34] fix: a task child retries once without effort when the model cannot think A stock Ollama catalog row lists no parameters, so the saved-catalog rule never applied and a task sent to that computer still asked `llama3.2` for effort and was refused with `does not support thinking`. Chat works on the same model only because it asks for no effort; task workers ask for low. Only the task child that `serve --take-tasks` runs now answers that exact 400 by sending the request once more without effort or a thinking budget, and remembers it in that client for later calls. Nothing is written to the shared quirks file, so no other process on that home changes, and plain `codeaf do` and chat keep dev's behavior. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- cmd/codeaf/do.go | 1 + cmd/codeaf/do_reasoning_test.go | 6 + .../2085-task-on-another-computer.md | 9 +- internal/config/config.go | 3 + .../manual/chat/tasks-on-another-computer.md | 21 +- internal/provider/client.go | 34 +++- internal/provider/refused_effort_test.go | 191 ++++++++++++++++++ internal/provider/wire.go | 5 + 8 files changed, 256 insertions(+), 14 deletions(-) create mode 100644 internal/provider/refused_effort_test.go diff --git a/cmd/codeaf/do.go b/cmd/codeaf/do.go index 734ee98b93..54ce43cdd1 100644 --- a/cmd/codeaf/do.go +++ b/cmd/codeaf/do.go @@ -3772,6 +3772,7 @@ func runErrand(request doRequest, seats config.Seats) (outcome headlessOutcome, // for this model, and a task must not wait for catalog discovery. build := newClient newClient = func(settings config.Config, model string) (*liveClient, error) { + settings.DropRefusedEffort = true service, _ := settings.Sources.OrDefault(settings.APIKey, settings.BaseURL).For(model) if strings.EqualFold(service.Source.ID, modelsource.DefaultID) { settings.Models = sharedCatalog(settings).SnapshotNow() diff --git a/cmd/codeaf/do_reasoning_test.go b/cmd/codeaf/do_reasoning_test.go index d2720a2073..1f12e317e6 100644 --- a/cmd/codeaf/do_reasoning_test.go +++ b/cmd/codeaf/do_reasoning_test.go @@ -120,6 +120,12 @@ func noReasoningDoRequests(t *testing.T, taskChild, direct bool) []map[string]js var stdout, stderr strings.Builder request := doRequest{task: "write out.txt", workspace: t.TempDir(), asJSON: true, model: model, planModel: model, checkModel: model, timeout: 30 * time.Second, slots: bound(1), stdout: &stdout, stderr: &stderr} + request.newClient = func(settings config.Config, model string) (*liveClient, error) { + if got := settings.ClientConfig(model).DropRefusedEffort; got != taskChild { + t.Errorf("refused-effort repair = %v, task child = %v", got, taskChild) + } + return newLiveClient(settings, model) + } if taskChild { attempt := t.TempDir() request.runStore = filepath.Join(attempt, "run") diff --git a/docs/changes/unreleased/2085-task-on-another-computer.md b/docs/changes/unreleased/2085-task-on-another-computer.md index 1ee6e1c815..bde8ed4aaf 100644 --- a/docs/changes/unreleased/2085-task-on-another-computer.md +++ b/docs/changes/unreleased/2085-task-on-another-computer.md @@ -18,9 +18,12 @@ task copies on. A computer that stops answering is shown as possibly still working with `run it here`; nothing re-runs by itself, and an abandoned attempt's late result never lands. Only a task that works in its own copy can go, both ends must be Linux or macOS, and a remote task runs alone in its -conversation. Every list that shows the task says running on or ran on the -computer, and a task worker there sends no reasoning effort to a model whose -saved catalog row lists no reasoning. +conversation. Task cards and landing notices, the task rail, Background work's +result row, `/history` task rows and task pages name the recorded computer; +the initial start receipt does not, and Home has no task row. A task worker +there omits effort when its catalog excludes reasoning, or retries once +without effort and remembers that choice in its client when an unknown model +explicitly refuses thinking. Plain `codeaf do` and chat keep their behavior. Everything is opt-in. With defaults left alone, pairing adds no relay traffic, and a local task's brief, records, the proposal card and the model's tool are diff --git a/internal/config/config.go b/internal/config/config.go index 3e6f2efc9c..3df76344a5 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -291,6 +291,8 @@ type Config struct { // rather than loaded here, because a config that fetched would make building // a client a network operation. Models *catalog.Catalog + // DropRefusedEffort is a runtime-only task-child option, never a saved setting. + DropRefusedEffort bool `json:"-"` } // ErrNoAPIKey is what [Load] returns when nothing — the two variables, the @@ -1034,6 +1036,7 @@ func WithoutSeatPin(configured provider.Config) provider.Config { func (c Config) ClientConfig(model string) provider.Config { configured := ClientConfigFor(c.Sources.OrDefault(c.APIKey, c.BaseURL), model) configured.Timeout = c.Timeout + configured.DropRefusedEffort = c.DropRefusedEffort // The published answer to "does this model take this field", from rows // already in memory. A nil catalog and a catalog still warming both say // "unknown", which the adapter treats as "send nothing on your own diff --git a/internal/manual/chat/tasks-on-another-computer.md b/internal/manual/chat/tasks-on-another-computer.md index ae219577ff..cadc7495f3 100644 --- a/internal/manual/chat/tasks-on-another-computer.md +++ b/internal/manual/chat/tasks-on-another-computer.md @@ -77,14 +77,19 @@ to this chat and task on this computer too. ## Models without thinking on the other computer -A task sent to another computer uses that computer's saved model catalog. -When the model's published supported parameters exclude reasoning, its task -worker sends no reasoning effort. The worker reads already available catalog -rows; it does not fetch a catalog to make this choice. If the model's supported -parameters are unknown, its existing effort behavior stays in place. - -This applies to tasks sent to that computer. Plain `codeaf do` still sends -its chosen effort. +A task worker on the other computer asks for `low` effort unless a task +choice sets another level. When its saved catalog row lists supported +parameters without reasoning, it sends no reasoning effort. + +A stock local Ollama row may list no parameters. If the model answers the +effort with `does not support thinking` (or `does not support reasoning`), the +task worker sends the same request again once without effort or a thinking +budget. Later calls through that task's client omit them too. This stays +within that client; other tasks and processes learn nothing from the refusal. +The worker reads available catalog rows without fetching a catalog for this +choice. + +Plain `codeaf do` and chat keep their existing effort and refusal behavior. ## My other computer stopped answering diff --git a/internal/provider/client.go b/internal/provider/client.go index db55163d4a..e96265bc25 100644 --- a/internal/provider/client.go +++ b/internal/provider/client.go @@ -50,6 +50,9 @@ type Config struct { // explicitly accepts an empty key. The ordinary keyless client remains the // first-run state and refuses before the wire. KeyOptional bool + // DropRefusedEffort lets a task child repair an explicit thinking refusal + // without teaching other clients or processes on this home. + DropRefusedEffort bool // BillingDoor is the person-facing name of a bound road. Empty is a service // with one road and preserves every older status line. BillingDoor string @@ -132,8 +135,10 @@ type Config struct { type Client struct { // Unsupported stream-usage options are remembered per endpoint, never per model. streamUsageUnsupported sync.Map - config Config - http *http.Client + // A task's refusal is local to this client and never enters persisted quirks. + refusedEffort sync.Map + config Config + http *http.Client // stream is the same client with the total deadline removed. A streamed // answer is bounded by silence, not by duration — see send. stream *http.Client @@ -793,12 +798,24 @@ func (c *Client) sendRepaired(ctx context.Context, request *ai.Request, knobs ca // The compatible usage option earns only its own repair. Peeking at an // unrelated refusal must not admit it to the older learning road. repairable := response.StatusCode == http.StatusBadRequest && c.repairable(model, knobs) - if !foreign && !repairable && !(streamUsage && usageRefusalStatus) { + dropEffort := c.config.DropRefusedEffort && c.refusedEffortRepairable(model, knobs, response.StatusCode) + if !foreign && !repairable && !dropEffort && !(streamUsage && usageRefusalStatus) { return response, nil } peek, readErr := io.ReadAll(io.LimitReader(response.Body, maxErrorPeek)) switch { case readErr != nil: + case dropEffort && !repaired["refused_effort"] && refusesRequestedEffort(peek): + c.record(recordFacts{ + ctx: ctx, request: request, knobs: knobs, stream: stream, + attempt: c.attemptsSoFar(knobs), began: began, + status: response.StatusCode, err: apiError(response.StatusCode, peek), responseBody: peek, + }) + c.refusedEffort.Store(normalizeModel(model), true) + repaired["refused_effort"] = true + began = logNow() + response, err = c.resend(ctx, request, knobs, stream, response) + continue case foreign && !repaired["foreign_reasoning"] && refusesForeignReasoning(peek): // THE WORDS STILL GO; ONLY THE OTHER MODEL'S THINKING STAYS HOME. A // sidecar tagged with its model never gets here (reasoning.go's @@ -856,6 +873,17 @@ func (c *Client) sendRepaired(ctx context.Context, request *ai.Request, knobs ca } } +// A task child may repair only an effort it actually sent, on the model's +// precise refusal. Other 400s remain the provider's own error. +func (c *Client) refusedEffortRepairable(model string, knobs callKnobs, status int) bool { + return status == http.StatusBadRequest && !knobs.relaxed.has(relaxReasoning) && c.resolveEffort(model, knobs.effort) != EffortNone +} + +func refusesRequestedEffort(body []byte) bool { + text := strings.ToLower(string(body)) + return strings.Contains(text, "does not support thinking") || strings.Contains(text, "does not support reasoning") +} + // resend closes a refused answer and sends the request again as the knobs now // say to shape it. The refusal generated no tokens, so the retry is free. func (c *Client) resend(ctx context.Context, request *ai.Request, knobs callKnobs, stream bool, refused *http.Response) (*http.Response, error) { diff --git a/internal/provider/refused_effort_test.go b/internal/provider/refused_effort_test.go new file mode 100644 index 0000000000..e2cdfb11b9 --- /dev/null +++ b/internal/provider/refused_effort_test.go @@ -0,0 +1,191 @@ +package provider + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "testing" + + "github.com/Agent-Field/agentfield/sdk/go/ai" + "github.com/Agent-Field/codeaf/internal/effort" +) + +// R10(a) repairs stock Ollama's explicit refusal only for the receiving task +// client, including its later calls; a new client must learn for itself. +func TestATaskChildResendsWithoutEffortWhenTheModelSaysItCannotThink(t *testing.T) { + for _, rung := range []effort.Rung{effort.Low, effort.Max} { + t.Run(string(rung), func(t *testing.T) { + fixture := noThinkingEndpoint(t) + client := fixture.client(t, true) + ctx := WithConfiguredEffortRung(context.Background(), rung) + for i := 0; i < 2; i++ { + if _, err := client.CompleteWithMessages(ctx, userMessages("write KIWI")); err != nil { + t.Fatalf("task's refused effort was not repaired: %v", err) + } + } + bodies := fixture.requests() + if len(bodies) != 3 || bodies[0]["reasoning"] == nil { + t.Fatalf("want one refused request, its repair, then one memoized call: %+v", bodies) + } + for _, body := range bodies[1:] { + if body["reasoning"] != nil || body["reasoning_effort"] != nil || body["thinking"] != nil { + t.Errorf("repaired request retained effort or budget: %+v", body) + } + } + if _, err := os.Stat(filepath.Join(fixture.profile, quirksFile)); !os.IsNotExist(err) { + t.Fatalf("the refusal escaped the client into the quirks file: %v", err) + } + fresh := fixture.client(t, false) + if _, err := fresh.CompleteWithMessages(ctx, userMessages("write KIWI")); err == nil { + t.Fatal("another client inherited the task client's memo") + } + if len(fixture.requests()) != 4 { + t.Fatal("the other client retried a refusal without opting in") + } + freshTask := fixture.client(t, true) + if _, err := freshTask.CompleteWithMessages(ctx, userMessages("write KIWI")); err != nil || len(fixture.requests()) != 6 { + t.Fatalf("another task client did not discover its own refusal: %v", err) + } + if _, err := client.CompleteWithMessages(ctx, userMessages("write KIWI"), ai.WithModel("another-model")); err != nil || len(fixture.requests()) != 8 { + t.Fatalf("one model's memo leaked into another model: %v", err) + } + }) + } +} + +// R10(a) bounds repair to the precise 400, a sent effort, and one resend. +func TestTaskEffortRepairMatchesOnlyTheExplicit400(t *testing.T) { + for _, tc := range []struct { + name, message string + status, requests int + sentEffort, succeeds bool + }{ + {"reasoning spelling", "DOES NOT SUPPORT REASONING", 400, 2, true, true}, + {"unrelated refusal", "context length is too large", 400, 1, true, false}, + {"other status", "does not support thinking", 422, 1, true, false}, + {"no effort sent", "does not support thinking", 400, 1, false, false}, + {"second refusal", "does not support thinking", 400, 2, true, false}, + } { + t.Run(tc.name, func(t *testing.T) { + var calls atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + call := calls.Add(1) + w.Header().Set("Content-Type", "application/json") + if call > 1 && tc.succeeds { + w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":"KIWI"},"finish_reason":"stop"}]}`)) + return + } + w.WriteHeader(tc.status) + json.NewEncoder(w).Encode(map[string]any{"error": map[string]string{"message": tc.message}}) + })) + defer server.Close() + client, err := NewClient(Config{APIKey: "test-key", BaseURL: server.URL, Model: "fixture/no-thinking", Direct: true, DropRefusedEffort: true}) + if err != nil { + t.Fatal(err) + } + ctx := context.Background() + if tc.sentEffort { + ctx = WithConfiguredReasoningEffort(ctx, EffortLow) + } + _, err = client.CompleteWithMessages(ctx, userMessages("write KIWI")) + if (err == nil) != tc.succeeds || int(calls.Load()) != tc.requests { + t.Fatalf("requests=%d, want %d; error=%v", calls.Load(), tc.requests, err) + } + }) + } +} + +// R10's stability rule keeps ordinary clients' explicit effort and 400 error. +func TestWithoutTheFlagARefusedEffortIsReturnedAsBefore(t *testing.T) { + fixture := noThinkingEndpoint(t) + ctx := WithConfiguredReasoningEffort(context.Background(), EffortLow) + _, err := fixture.client(t, false).CompleteWithMessages(ctx, userMessages("write KIWI")) + if err == nil || !strings.Contains(err.Error(), `API error (400): "llama3.2:latest" does not support thinking`) { + t.Fatalf("ordinary refusal changed: %v", err) + } + if bodies := fixture.requests(); len(bodies) != 1 || bodies[0]["reasoning"] == nil { + t.Fatalf("ordinary call changed or retried: %+v", bodies) + } +} + +// R10(a) requires an effort on the actual wire. The endpoint ladder can +// already have removed it before this refusal loop gets the request. +func TestAnAlreadyRemovedEffortDoesNotTeachTheTaskClient(t *testing.T) { + var recorded capture + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + recorded.record(r) + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte(`{"error":{"message":"does not support thinking"}}`)) + })) + defer server.Close() + client, err := NewClient(Config{APIKey: "test-key", BaseURL: server.URL, Model: "fixture/no-thinking", Direct: true, DropRefusedEffort: true}) + if err != nil { + t.Fatal(err) + } + request := &ai.Request{Model: "fixture/no-thinking", Messages: userMessages("write KIWI")} + response, err := client.sendRepaired(context.Background(), request, callKnobs{effort: effortRequest{effort: EffortLow, explicit: true}, relaxed: relaxReasoning}, false) + if err != nil { + t.Fatal(err) + } + response.Body.Close() + if response.StatusCode != 400 || recorded.count() != 1 || recorded.body(0)["reasoning"] != nil { + t.Errorf("an already effortless request earned a repair: status %d, requests %d", response.StatusCode, recorded.count()) + } + ctx := WithConfiguredReasoningEffort(context.Background(), EffortLow) + _, err = client.CompleteWithMessages(ctx, userMessages("write KIWI")) + if err == nil || recorded.count() != 3 || recorded.body(1)["reasoning"] == nil { + t.Errorf("an effortless refusal changed the later ordinary attempt: requests %d, error %v", recorded.count(), err) + } +} + +type noThinkingService struct { + server *httptest.Server + profile string + mu sync.Mutex + bodies []map[string]any +} + +func noThinkingEndpoint(t *testing.T) *noThinkingService { + t.Helper() + forgetLanes(t) + f := &noThinkingService{profile: quirksAt(t, "llama3.2:latest")} + f.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Error(err) + } + f.mu.Lock() + f.bodies = append(f.bodies, body) + f.mu.Unlock() + w.Header().Set("Content-Type", "application/json") + if body["reasoning"] != nil || body["reasoning_effort"] != nil || body["thinking"] != nil { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte(`{"error":{"message":"\"llama3.2:latest\" does not support thinking"}}`)) + return + } + w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":"KIWI"},"finish_reason":"stop"}]}`)) + })) + t.Cleanup(f.server.Close) + return f +} + +func (f *noThinkingService) client(t *testing.T, repair bool) *Client { + t.Helper() + c, err := NewClient(Config{APIKey: "test-key", BaseURL: f.server.URL, Model: "llama3.2:latest", Direct: true, DropRefusedEffort: repair}) + if err != nil { + t.Fatal(err) + } + return c +} + +func (f *noThinkingService) requests() []map[string]any { + f.mu.Lock() + defer f.mu.Unlock() + return append([]map[string]any(nil), f.bodies...) +} diff --git a/internal/provider/wire.go b/internal/provider/wire.go index 517b9baa1a..69ea911baf 100644 --- a/internal/provider/wire.go +++ b/internal/provider/wire.go @@ -480,6 +480,11 @@ func (c *Client) effortAsked(requested effortRequest) effortRequest { // resolveEffort decides whether the knob may travel, and in what shape. func (c *Client) resolveEffort(model string, requested effortRequest) Effort { + if c.config.DropRefusedEffort { + if _, refused := c.refusedEffort.Load(normalizeModel(model)); refused { + return EffortNone + } + } requested = c.effortAsked(requested) effort := c.requestedEffort(model, requested) // A model that reasons unconditionally answers the disable with a 400, and From 4c4d05e56107a4dfcaa0d38261c7a15f81c92d75 Mon Sep 17 00:00:00 2001 From: Abir Abbas <abirabbas1998@gmail.com> Date: Sat, 31 Oct 2026 05:44:10 -0400 Subject: [PATCH 34/34] docs: the chats-stored heading carries the words people ask with "where are my chats stored" sat fourth on dev, one place inside the manual's four results, and this change's new pages moved it to fifth. The heading now leads with the asker's wording and keeps the old title after it, so the cross-reference in running-from-the-terminal still finds it; the probe reaches it second. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/manual/chat/use-this-on-another-computer.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/manual/chat/use-this-on-another-computer.md b/internal/manual/chat/use-this-on-another-computer.md index 5f461ee26a..f586ac04a7 100644 --- a/internal/manual/chat/use-this-on-another-computer.md +++ b/internal/manual/chat/use-this-on-another-computer.md @@ -282,7 +282,7 @@ If something reaches for the relay while quiet, it gets the word `sync is waitin The first-run line about the hosted relay is said once, when chats are first copied, after pairing or offering a code. `CODEAF_SYNC_URL=off` keeps even a paired machine from contacting a relay, including pairing with `--via`. -## Where your chats are stored — the hosted relay, turning it off, self-host your own relay, is my code private +## Where are my chats stored — where your chats are stored, the hosted relay, turning it off, self-host your own relay, is my code private Moving chats and pairing are available by default on Linux, macOS and native Windows on every build with a compatible engine. Pair both computers first.