diff --git a/cmd/codeaf/chatv3_at.go b/cmd/codeaf/chatv3_at.go index 0f05496e07..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,6 +302,9 @@ func runServe(args []string) error { ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() defer startServeKeeping(ctx, service)() + if *takeTasks { + defer serveTasks(ctx, service)() + } host := &pair.Host{ Service: service, Device: device, 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_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_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/cmd/codeaf/chatv3_taskcomputers.go b/cmd/codeaf/chatv3_taskcomputers.go new file mode 100644 index 0000000000..2e7712f137 --- /dev/null +++ b/cmd/codeaf/chatv3_taskcomputers.go @@ -0,0 +1,53 @@ +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/directory" + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/identity" + "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 { + // 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 + } + route := syncsetup.Resolve(profile) + if !route.On() { + return nil + } + id, err := identity.Load(profile) + if err != nil { + return nil + } + device, err := identity.LoadDevice(profile) + 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 + 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_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 +} 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") + } + } +} diff --git a/cmd/codeaf/do.go b/cmd/codeaf/do.go index ceee130452..54ce43cdd1 100644 --- a/cmd/codeaf/do.go +++ b/cmd/codeaf/do.go @@ -24,21 +24,25 @@ 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" "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" 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" 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 +331,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 +371,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 +410,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 +449,7 @@ type doRequest struct { run string database string keep bool + runStore string workspace string timeout time.Duration asJSON bool @@ -769,6 +780,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 +3652,16 @@ 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 != "" { + 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() + } + 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 +3682,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 +3724,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 +3732,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 @@ -3735,6 +3766,22 @@ 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) { + 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() + } 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 @@ -3747,7 +3794,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 +3832,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_reasoning_test.go b/cmd/codeaf/do_reasoning_test.go new file mode 100644 index 0000000000..1f12e317e6 --- /dev/null +++ b/cmd/codeaf/do_reasoning_test.go @@ -0,0 +1,159 @@ +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} + 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") + 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 +} 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..84af088414 --- /dev/null +++ b/cmd/codeaf/do_task_store.go @@ -0,0 +1,88 @@ +package main + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "time" + + "github.com/Agent-Field/codeaf/internal/crewroute" + "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 + } + 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") + } + 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/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 · status [] [--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/cmd/codeaf/serve_tasks.go b/cmd/codeaf/serve_tasks.go new file mode 100644 index 0000000000..b7e5003d14 --- /dev/null +++ b/cmd/codeaf/serve_tasks.go @@ -0,0 +1,88 @@ +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/guard" + "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{}) + 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 } +} + +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/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/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..bde8ed4aaf --- /dev/null +++ b/docs/changes/unreleased/2085-task-on-another-computer.md @@ -0,0 +1,31 @@ +--- +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 `, 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 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. 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 +byte-identical to before; the Run tasks on settings row is the only new thing a +paired person sees. 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/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/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/settings.go b/internal/config/settings.go index 8f7d2c392c..2f6938090f 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 @@ -1781,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. @@ -1834,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) } @@ -2411,6 +2436,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 +4345,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) + } +} 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/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 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 --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) + }) + 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) + } +} + +// 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 + 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() { + return f.startServe(t, true) +} + +func (f *otherHomeTask) startServe(t *testing.T, takeTasks bool) func() { + t.Helper() + log := filepath.Join(f.b, "serve.log") + 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", func() bool { + raw, _ := os.ReadFile(log) + return strings.Contains(string(raw), ready) + }) + 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}) +} 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..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 @@ -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 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 ` 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 option starts plain +`codeaf serve` and takes no tasks; sending one says +` is not taking tasks right now — run codeaf serve --take-tasks on it`, and +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. + ## How do I remove my dev box or stop paying for it? Run `codeaf devbox down ` with the Railway token set. It asks 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 "" -``` - -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 ""` 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 40s ago — 4m30s`. +## How do I run codeaf without the screen — headless exit codes and questions + +``` +codeaf do "" +``` + +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/commands.md b/internal/manual/chat/commands.md index 5befa40ba7..d089952e2f 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 --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; +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..85db2510f6 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 --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`. + +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,20 @@ 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 --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 +`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..a7e91eceb6 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 +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 `. 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..966f88e073 100644 --- a/internal/manual/chat/reaching-this-machine-without-ssh.md +++ b/internal/manual/chat/reaching-this-machine-without-ssh.md @@ -48,11 +48,21 @@ 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. +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 ` 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: ``` -codeaf serve [--workspace path] [--via https://…] +codeaf serve [--workspace path] [--via https://…] [--take-tasks] ``` It prints, exactly: diff --git a/internal/manual/chat/settings.md b/internal/manual/chat/settings.md index 11a457ff4d..265b534323 100644 --- a/internal/manual/chat/settings.md +++ b/internal/manual/chat/settings.md @@ -135,6 +135,17 @@ 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 +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 --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 **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..cadc7495f3 --- /dev/null +++ b/internal/manual/chat/tasks-on-another-computer.md @@ -0,0 +1,385 @@ +# Tasks on another computer + +## Can a task run on my other computer + +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 ` 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 +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. +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. + +## Run a task on my mac 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: + +``` +/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 `. 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. 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 +`"" matches more than one paired computer — choose one of: `. +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 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 +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. + +## Models without thinking on the other computer + +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 + +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 --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 +` is not taking tasks right now — run codeaf serve --take-tasks 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. 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 + +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, 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 +`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 --take-tasks takes tasks from paired computers + +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 +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 +`<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. + +## 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. + +## 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/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. diff --git a/internal/manual/chat_test.go b/internal/manual/chat_test.go index 8b8dbf0af8..c23d1a3973 100644 --- a/internal/manual/chat_test.go +++ b/internal/manual/chat_test.go @@ -33,6 +33,40 @@ func TestTheChatManualAnswersTheQuestionsPeopleAsk(t *testing.T) { question string 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"}, + {"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"}, + {"run it here", "tasks-on-another-computer"}, + {"run tasks on", "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"}, + {"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"}, {"set up a dev box", "a-dev-box-on-your-own-railway"}, 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) + } + }) + } +} diff --git a/internal/pair/task_tunnel_test.go b/internal/pair/task_tunnel_test.go new file mode 100644 index 0000000000..681c98ccb2 --- /dev/null +++ b/internal/pair/task_tunnel_test.go @@ -0,0 +1,159 @@ +package pair + +import ( + "bytes" + "crypto/ecdh" + "crypto/rand" + "io" + "net" + "testing" + "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) { + 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..bc1cd5e59d 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 connectTaskTunnel(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 := acceptTaskTunnel(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. @@ -207,6 +244,99 @@ func acceptAsMachine(conn io.ReadWriteCloser, me Device, allow func(key []byte, 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 + } + handshake, err := noise.NewHandshakeState(noise.Config{ + CipherSuite: suite, + Pattern: noise.HandshakeIK, + Initiator: true, + Prologue: []byte(prologue), + StaticKeypair: static, + 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, binding: append([]byte(nil), handshake.ChannelBinding()...), send: first, recv: second}, nil +} + +// 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 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, + Initiator: false, + Prologue: []byte(prologue), + StaticKeypair: static, + }) + 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, binding: append([]byte(nil), handshake.ChannelBinding()...), send: second, recv: first}, deviceKey, nil +} + // withDeadline puts a bound on a handshake when the stream underneath carries // deadlines, as both TCP sockets and relay mux streams do, so an unpaired // caller that opens a stream and says nothing cannot hold a slot forever. 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 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..1cde1e9903 100644 --- a/internal/remote/server.go +++ b/internal/remote/server.go @@ -1107,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{}{} @@ -1296,6 +1299,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 +2137,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 +2443,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..13fa9436f7 --- /dev/null +++ b/internal/remote/taskcomputers_test.go @@ -0,0 +1,250 @@ +package remote + +import ( + "bytes" + "context" + "encoding/json" + "strings" + "sync" + "testing" + + "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 + 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. 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/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 ed5723ad89..c90b79ac22 100644 --- a/internal/run/trajectory.go +++ b/internal/run/trajectory.go @@ -16,6 +16,8 @@ import ( "os" "path/filepath" "strings" + "sync" + "sync/atomic" "time" "unicode/utf8" @@ -55,6 +57,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 +191,60 @@ func Trajectory(storeDir, id string) ([]Step, error) { return steps, nil } +// 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 + 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 { + 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] + 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 +260,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 } @@ -221,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) } +} 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 d1432de732..3fea8aa033 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.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/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/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/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/session.go b/internal/session/session.go index db8ccbe496..b5958f9efb 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,10 +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 { - 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/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/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/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..4ee90c0995 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.mayNameTaskComputer() { + schema = strings.Replace(schema, `"properties":{`, `"properties":{`+taskComputerSchema, 1) + } if WorkspaceOwnershipEnabled(a.config.Place) { schema = strings.Replace(schema, `"properties":{`, `"properties":{`+workspaceSetTaskSchema+`,`, 1) } @@ -705,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 @@ -714,6 +725,9 @@ func (a *Agent) stageTask(ctx context.Context, args json.RawMessage) bare.Staged } return bare.Settled(problem, 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 // resolve are all "do not start this"; they live in one helper so this @@ -875,6 +889,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 @@ -963,7 +983,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 !a.proposalUsesRunRoad(spec) { return "", false, false } a.mu.Lock() @@ -977,7 +997,25 @@ func (a *Agent) commitProposalToRun(ctx context.Context, p *stagedProposal, spec } via = &m } - description := proposalBrief(spec, via) + 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 := 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 @@ -1139,7 +1177,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 +1603,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, @@ -1836,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_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_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_computer_manual_test.go b/internal/session/task_computer_manual_test.go new file mode 100644 index 0000000000..9405a95be6 --- /dev/null +++ b/internal/session/task_computer_manual_test.go @@ -0,0 +1,48 @@ +package session + +import ( + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/config" +) + +// 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, preferred, inTask, offered bool + }{ + {"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"] + } + 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) + } + }) + } +} diff --git a/internal/session/task_contract.go b/internal/session/task_contract.go index 0f6f15ab08..7160117878 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 `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 @@ -858,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 new file mode 100644 index 0000000000..36b2adcbe2 --- /dev/null +++ b/internal/session/task_elsewhere.go @@ -0,0 +1,321 @@ +package session + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "sort" + "strings" + "time" + + "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. +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 + OriginalName string `json:"originalName,omitempty"` + 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 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 +} + +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)) { + 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 + } + 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 + } + 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 { + 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 + } + 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 + } + p.computer = &computer + return p +} + +// StartTaskOn is the shared session entry for a person's explicit placement. +// Model proposals reach the same resolver through their normal admission road. +// 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 { + 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 +} + +// 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 } + +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. +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 +} + +// 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) != "" +} + +// 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.taskExecutorEngine(ctx, TaskExecutor{Device: placement.computer.Device, Name: placement.computer.Name}) + 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_elsewhere_test.go b/internal/session/task_elsewhere_test.go new file mode 100644 index 0000000000..42403b45e1 --- /dev/null +++ b/internal/session/task_elsewhere_test.go @@ -0,0 +1,348 @@ +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.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) + } +} + +// 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) { + 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") + } + }) + 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, 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") + 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_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_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_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_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 new file mode 100644 index 0000000000..b73a8c231e --- /dev/null +++ b/internal/session/task_remote_control.go @@ -0,0 +1,301 @@ +package session + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "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(taskComputersUnavailable) + } + 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, 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 { + 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 { + 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 { + return nil, errors.New("another task is already running in this conversation") + } + r := &taskHereReplacement{kept: kept, live: live} + if err := r.openRecord(ctx, a, g); err != nil { + r.releaseRecord() + return nil, err + } + 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 !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 { + return nil, err + } + if err := r.engine.CheckCopy(ctx, r.tree.dir, r.record.Fingerprint); err != nil { + return nil, err + } + r.record.Abandoned, r.record.AckPending = true, true + if err := writeRemoteRecord(r.store, *r.record); err != nil { + return nil, err + } + if live != nil { + live.remote = cloneTaskRemote(r.record) + } + 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 + } + 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 + } + var err error + r.store, err = openRemoteTaskPlan(g, r.kept.ID) + if err != nil { + return err + } + r.record = readRemoteRecord(r.store, r.kept.Remote) + if r.record == nil { + return errors.New("this task is not waiting on another computer") + } + 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() + } +} +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() + } + 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: 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, r.brief)) + return "running " + taskStopName(row, kept.Title) + " here", nil +} + +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() + 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") +} + +// 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 new file mode 100644 index 0000000000..81b222f143 --- /dev/null +++ b/internal/session/task_remote_money.go @@ -0,0 +1,166 @@ +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 + } + newline, err := memo.readTail(f) + if err != nil { + return err + } + if memo.seen[line.RemoteEvent] { + return nil + } + raw, err := json.Marshal(line) + if err != nil { + return err + } + if newline { + raw = append([]byte{'\n'}, raw...) + } + 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 +} + +// 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 { + 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..341e3baa44 --- /dev/null +++ b/internal/session/task_remote_record.go @@ -0,0 +1,412 @@ +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 { + // 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) + } + 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 { + if err := mirror.readPrefix(path, attempt); 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["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 +} + +// 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) { + 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.Executor.Device == "" { + 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 "" +} + +// 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 +} + +// 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_remote_recovery_test.go b/internal/session/task_remote_recovery_test.go new file mode 100644 index 0000000000..45446926ce --- /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 --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 { + 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..5289445970 --- /dev/null +++ b/internal/session/task_remote_watch.go @@ -0,0 +1,186 @@ +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{} + } + state := taskWatchState{clock: clock, last: clock.Now(), delay: time.Second, reachability: reachability} + for { + var err error + var retry bool + from, err, retry = state.follow(ctx, from, watch, consume) + if !retry { + return err + } + if err == nil || errors.Is(err, ErrTaskWatchEnded) { + return nil + } + if ctx.Err() != nil { + return ctx.Err() + } + if err := state.wait(ctx); err != nil { + return err + } + } +} + +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: + } + } + 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. +var ErrTaskWatchEnded = errors.New("the task watch ended") 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_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..d5789e5be6 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,16 +376,22 @@ 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 - // 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. + 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 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 @@ -582,6 +606,10 @@ func (a *Agent) startOrJoinTaskRunVia(ctx context.Context, id uint64, title, bri return false, err } } + ctx, engine, err = a.placeTaskRunEngine(ctx, engine, stand, via != nil) + if err != nil { + return false, err + } path := g.planPath() storeID := strconv.FormatUint(id, 10) dependencies := make([]plandb.Dependency, 0, len(dependsOn)) @@ -651,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) @@ -712,6 +740,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 +752,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 @@ -740,13 +770,13 @@ 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 { + if via == nil && taskPlacementOf(ctx).computer == nil { crew, err = a.routeTaskCrew(ctx, id, title, brief) if err != nil { return false, err @@ -768,22 +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), + 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{}) @@ -797,7 +834,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,14 +950,10 @@ func (a *Agent) joinOrWait(ctx context.Context, stand taskStand, id uint64, titl } over := live.over if !live.ending && !live.closing && !live.stopped { - 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, }}) @@ -1688,6 +1721,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,12 +1815,17 @@ 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 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() @@ -1846,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 @@ -1860,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 @@ -1884,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) @@ -1898,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 } @@ -1912,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) { @@ -1951,9 +2008,21 @@ 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) + summary := startPlacedBeltRun(ctx, engine, run, spec) + if run.remoteEngine != nil { + engine = chatRunEngine + } + if run.remoteAbandoned() { + 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. @@ -1963,16 +2032,14 @@ 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 - run.ending = true - closing := run.closing - a.beltMu.Unlock() + closing := a.markBeltRunEnding(run, summary) defer a.releaseBeltRun(run) - defer a.parkBeltRunStorage(run, summary) + 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. - 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,25 +2049,40 @@ func (a *Agent) driveBeltRun(ctx context.Context, engine RunEngine, run *beltRun if run.cut != nil { defer run.cut() } + 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 } - landing := a.finishBeltRunLanding(ctx, engine, run, summary) + 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. + // 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 +2092,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)) @@ -2038,6 +2122,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 @@ -2476,26 +2598,12 @@ 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 + state, report := beltRunResultWords(summary, landing) + if run.remoteEngine != nil && summary.NeedsDecision { + state = TaskUnverified } - 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 + if run.remoteEngine != nil && summary.Outcome == "interrupted" { + state = TaskInterrupted } notice := TaskNotice{ ID: run.row, Title: run.title, State: state, @@ -2511,11 +2619,14 @@ 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 { + run.fillRemoteLandingNotice(¬ice, summary) + } 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. @@ -2534,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 @@ -2692,6 +2821,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() } @@ -2750,3 +2884,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 189e2da648..be2cd004a9 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, remote := remoteRowToContinue(g, row); remote { + 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 @@ -62,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) @@ -150,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_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..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" @@ -23,6 +24,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 +40,9 @@ func (a *Agent) recoverBeltRun() { return } kept, found := runRowOf(g, id) + if a.recoverRemoteBeltRun(g, store, &kept, found) { + return + } if !found || kept.State != TaskInterrupted { return } @@ -171,10 +176,17 @@ 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, 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 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 @@ -239,3 +251,76 @@ 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.resumeRemotePreparation(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 +} + +// 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_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/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/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/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/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/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{}) 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 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 +} 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/attempts.go b/internal/taskdoor/attempts.go new file mode 100644 index 0000000000..eba99660f0 --- /dev/null +++ b/internal/taskdoor/attempts.go @@ -0,0 +1,454 @@ +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"` + 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. +// 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 + finished map[string]time.Time +} + +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{}, 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 + } + 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} + _, 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 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 + // 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 _, 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 + 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 := 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()) { + 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 + } + 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 { + _ = os.RemoveAll(a.Dir) + 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/copy.go b/internal/taskdoor/copy.go new file mode 100644 index 0000000000..e92764727e --- /dev/null +++ b/internal/taskdoor/copy.go @@ -0,0 +1,484 @@ +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) + // BeforeInstall observes the prepared result before filesystem replacement. + // Tests use this boundary to reproduce an editor racing installation. + BeforeInstall func() +} + +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 { + 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 nil, err + } + if raw, err := hex.DecodeString(in.Head); err != nil || len(raw) != sha256.Size { + return nil, errors.New("the task copy's head is not readable") + } + unlock, err := d.lock(ctx, c.ID) + if err != nil { + return nil, err + } + 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 nil, err + } + } + if err := os.MkdirAll(c.Root, 0700); err != nil { + 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 nil, err + } + result.measure.Fetch = time.Since(start) + result.restoreStarted = time.Now() + if err := syncer.Materialize(ctx, c, in.Head); err != nil { + return nil, err + } + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + return nil, err + } + result.stage, err = os.MkdirTemp(filepath.Dir(path), ".task-arrival-") + if err != nil { + return nil, 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 nil, err + } + if fingerprint != "" { + if err := taskcopy.ImportCopyHistory(path, result.prepared); err != nil { + return nil, err + } + } + 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(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 + } + 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 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 r.rollback(previous, moved, err) + } + } + 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(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) + } + } + if r.unlock != nil { + r.unlock() + r.unlock = 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 { + 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 nil, errors.New("the sent task copy's fingerprint is missing") + } + return prepareCopy(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() +} + +// 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/copy_test.go b/internal/taskdoor/copy_test.go new file mode 100644 index 0000000000..e1c908ebde --- /dev/null +++ b/internal/taskdoor/copy_test.go @@ -0,0 +1,333 @@ +//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/taskdoor" +) + +func copyHomes(t *testing.T) (taskdoor.CopyDeps, taskdoor.CopyDeps) { + t.Helper() + r := newRig(t) + 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 + }, 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 := t.Context() + 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) + } + } + 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/dial.go b/internal/taskdoor/dial.go new file mode 100644 index 0000000000..cef26cf00b --- /dev/null +++ b/internal/taskdoor/dial.go @@ -0,0 +1,216 @@ +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) +} + +// 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/door_test.go b/internal/taskdoor/door_test.go new file mode 100644 index 0000000000..08a52bb740 --- /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 --take-tasks 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/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 new file mode 100644 index 0000000000..227ffbae01 --- /dev/null +++ b/internal/taskdoor/executor.go @@ -0,0 +1,643 @@ +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/crewroute" + "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 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 + 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() + var observedExit *int + for { + if err := ctx.Err(); err != nil { + return err + } + if err := follower.poll(j); err != nil { + return err + } + code := observedExit + ended := code != nil + if done != nil && !ended { + select { + case exit := <-done: + code = &exit + ended = true + default: + } + } else if done == nil { + 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(): + case exit := <-done: + // A reaped child must report its receipt without waiting for another poll tick. + observedExit = &exit + } + } +} + +// 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/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/frames.go b/internal/taskdoor/frames.go new file mode 100644 index 0000000000..4f7f99ee67 --- /dev/null +++ b/internal/taskdoor/frames.go @@ -0,0 +1,239 @@ +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"` + Fingerprint string `json:"-"` +} +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"` + Source string `json:"source,omitempty"` + Offset int64 `json:"offset,omitempty"` +} + +// 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"` + Source string `json:"source,omitempty"` + Offset int64 `json:"offset,omitempty"` +} +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..90e851b0a2 --- /dev/null +++ b/internal/taskdoor/hello.go @@ -0,0 +1,130 @@ +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" + TakeTasksFlag = "take-tasks" + notTakingTasksSuffix = " is not taking tasks right now — run codeaf serve --" + TakeTasksFlag + " on it" +) + +func NotTakingTasks(name string) string { + return name + notTakingTasksSuffix +} +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) (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 hello.Cert.DeviceID(), 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/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/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/registration.go b/internal/taskdoor/registration.go new file mode 100644 index 0000000000..9bafa59483 --- /dev/null +++ b/internal/taskdoor/registration.go @@ -0,0 +1,139 @@ +package taskdoor + +import ( + "context" + "crypto/ecdh" + "errors" + "fmt" + "time" + + "github.com/Agent-Field/codeaf/internal/pair" + "github.com/Agent-Field/codeaf/internal/relay" +) + +// 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) + 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(pair.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(pair.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 pair.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 pair.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(pair.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) >= time.Minute { + 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/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/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/security_test.go b/internal/taskdoor/security_test.go new file mode 100644 index 0000000000..040858b2b1 --- /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 --take-tasks 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..ba1fa8e1ca --- /dev/null +++ b/internal/taskdoor/serve.go @@ -0,0 +1,375 @@ +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") + } + 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 + } + 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 (®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) + 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 + } + device, err := acceptHello(handshakeCtx, tunnel, d.config.Identity, d.config.Device, d.roster, d.name) + if 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 + } + 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": + 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, device) + 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 func() { + if ctx.Err() == nil { + 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, 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 + default: + } + if err := conn.sendFrame(frame); err != nil { + return + } + from = frame.Seq + } + if finished { + conn.watching.Store(false) + return + } + 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 + } + } + } + } +} + +// 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, 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, 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 +} diff --git a/internal/taskplace/computers.go b/internal/taskplace/computers.go new file mode 100644 index 0000000000..e71abd3134 --- /dev/null +++ b/internal/taskplace/computers.go @@ -0,0 +1,95 @@ +// 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) + } + 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 + } + 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/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/taskplace/engine.go b/internal/taskplace/engine.go new file mode 100644 index 0000000000..925c20031b --- /dev/null +++ b/internal/taskplace/engine.go @@ -0,0 +1,354 @@ +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 && 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) + 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 + 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) + 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_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 new file mode 100644 index 0000000000..755d447592 --- /dev/null +++ b/internal/taskplace/engine_test.go @@ -0,0 +1,253 @@ +//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/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 := taskEngineBinary(t) + 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) + } +} 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") + } +} diff --git a/internal/tui3/app.go b/internal/tui3/app.go index 20dda15fe0..8a317bf736 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.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/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/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 diff --git a/internal/tui3/input.go b/internal/tui3/input.go index 74f492e4b5..41f2bd8e97 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 } diff --git a/internal/tui3/place_sessions.go b/internal/tui3/place_sessions.go index 6a23275f5b..e10b7a17bc 100644 --- a/internal/tui3/place_sessions.go +++ b/internal/tui3/place_sessions.go @@ -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/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..4a87104a7b 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.` @@ -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 @@ -2627,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 — @@ -2822,6 +2844,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 +3800,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..92bd6145ea 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 @@ -46,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 @@ -80,6 +85,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..1aaf055791 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,11 @@ 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 + // 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. workTurn int workOwned bool @@ -905,6 +913,9 @@ func (a *app) taskEvent(ev session.Event) tea.Cmd { a.note(ev.Text) case session.EventTaskProposal: a.proposeTask(ev) + if a.proposalComputerName(a.task) != "" { + pilot = a.readTaskComputers() + } case session.EventTaskUpdate: pilot = a.taskUpdate(ev) mentions = a.refreshTasks() @@ -1290,22 +1301,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 +1606,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 +1620,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 +1668,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 @@ -1921,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()) @@ -1961,6 +1995,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 +2234,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) } @@ -4251,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. @@ -4269,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) } @@ -4390,6 +4441,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 +4820,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 +5354,7 @@ func (a *app) taskUpdate(ev session.Event) tea.Cmd { resumed = a.resumeRetriedRoom(node) } node.state = notice.State + node.takeComputerNotice(notice) if len(notice.DependsOn) > 0 { node.dependsOn = notice.DependsOn } 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/taskcommand.go b/internal/tui3/taskcommand.go index 7644a11dfa..f4a38426a0 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 <brief> · /task solo <brief> · /task --best <brief> · /task --cheap <brief>" + if _, ok := a.agent.(taskCommandOnAgent); ok && a.taskComputersAvailable() { + usage += " · /task --on <computer> <brief>" + } + 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,27 @@ func (a *app) runTaskCommand(arg string) tea.Cmd { } } if brief == "" { - a.note("usage: /task <brief> · /task solo <brief> · /task --best <brief> · /task --cheap <brief>") + a.note(a.taskCommandUsage()) return nil } + if computer != "" { + solo = solo || config.TaskStartAt(a.profileDir) == config.TaskStartSingle + 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 { + 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 foldTaskStarted(taskStartedMsg{kind: "single", id: strconv.FormatUint(id, 10), title: title, note: note, err: err, brief: brief, conv: conv}) + }) + } 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..0a32e45b4d --- /dev/null +++ b/internal/tui3/taskcomputer.go @@ -0,0 +1,286 @@ +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 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 + } + 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 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) + } + a.touch() + return nil + } + }) +} + +func (a *app) proposalComputerName(card *taskCard) string { + if card == nil || !a.taskComputersAvailable() || (card.computer == "" && card.computerPreference == "") { + 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.proposalComputerName(card) != "" && 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. +// 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 "" + } + 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 "" +} + +// 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 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 node := a.taskSheetNodeFor(&entry); node != nil { + 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)) +} + +// 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 new file mode 100644 index 0000000000..af1986bc15 --- /dev/null +++ b/internal/tui3/taskcomputer_test.go @@ -0,0 +1,484 @@ +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 --take-tasks 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 }{ + {"", "", ""}, + {"Abir's Mac", "", "Abir's Mac"}, + {"", 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) + 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) + } + }) + } +} + +// 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} + 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 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, "ran on work computer") { + t.Fatalf("a saved executor disappeared with the controls: %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 --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 { + 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/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/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/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 86bed7d19d..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)) } @@ -349,6 +354,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/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/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/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..afcf9f3b00 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) + // 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 { entry.Crew = node.crew } diff --git a/internal/tui3/tasksplace.go b/internal/tui3/tasksplace.go index 4fd31d6119..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) } @@ -364,7 +388,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)], @@ -2028,6 +2056,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 +2188,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/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/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" +} 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 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) }